I've been running Home Assistant on a Raspberry Pi for over two years. It controls my Hue lights, Zigbee devices, the usual stuff. It's always been a local network thing.
As every vibecoder knows, you've got it working on https://localhost:3000 but that doesn't mean you can show your friends type of thing.
I had this little home network but I never wanted to expose it to the broader internet.
This past weekend, I spun up a $4/month DigitalOcean droplet to run ntfy, a self-hosted push notification service. The idea was to give various automations a way to send push notifications to my phone.
Then I realized: now I've got a droplet which can serve as my internet-facing endpoint. That means I can probably gateway to trigger things on my home network via HomeAssistant
So, I now have a URL that anyone with a token can hit to flash my office lights red - pretty much an IRL ping that something needs my attention.
The Problem
My Pi is behind my home router. I don't want to port-forward or expose Home Assistant directly. But I want to trigger it from the internet.
The droplet solves this. It's public-facing. The question is: how do I get from the droplet to my Pi securely?
The Solution: Tailscale
Tailscale creates a mesh VPN between devices. Install it on both the droplet and the Pi, and they can talk to each other using private IPs (like 100.x.x.x) - no port forwarding needed.
Internet → Droplet (public) → Tailscale → Pi (private) → Home Assistant
What Claude Code Built
I used Claude Code to wire this up. My key insight was that I could simply give Claude Code SSH access to both my Pi and Droplet and let it handle a lot of the rest.
I described what I wanted, and it:
- SSHed into my Pi and queried Home Assistant to find my light entity IDs
- Wrote a bash script that flashes the lights red, then restores the previous color
- Installed Tailscale on both the Pi and droplet
- Generated SSH keys so the droplet can run commands on the Pi
- Created a Flask webhook with token-based auth
- Set up nginx to route requests
- Created systemd services so everything survives reboots
The whole thing took maybe 20 minutes. Most of that was waiting for apt to install packages.
The Architecture
Request: GET /flash-peter-office-lights?auth_token=xxx
↓
Cloudflare (HTTPS)
↓
DigitalOcean Droplet
nginx → Flask (port 5000)
↓
Tailscale (100.x.x.x)
↓
Raspberry Pi
SSH → flash_lights.sh
↓
Home Assistant API
↓
Lights flash red → restore
The Flash Script
The tricky part is restoring the lights to their previous state. Home Assistant lights can be in different color modes, so the script saves the current state before flashing:
# Save current state
STATE=$(curl -s -H "Authorization: Bearer $HA_TOKEN" \
"http://localhost:8123/api/states/light.office")
WAS_ON=$(echo $STATE | jq -r '.state')
BRIGHTNESS=$(echo $STATE | jq -r '.attributes.brightness // 255')
XY_X=$(echo $STATE | jq -r '.attributes.xy_color[0] // empty')
XY_Y=$(echo $STATE | jq -r '.attributes.xy_color[1] // empty')
# Flash red
curl -s -X POST "http://localhost:8123/api/services/light/turn_on" \
-H "Authorization: Bearer $HA_TOKEN" \
-d '{"entity_id": "light.office", "rgb_color": [255, 0, 0], "brightness": 255}'
sleep 1
# Restore
curl -s -X POST "http://localhost:8123/api/services/light/turn_on" \
-H "Authorization: Bearer $HA_TOKEN" \
-d "{\"entity_id\": \"light.office\", \"brightness\": $BRIGHTNESS, \"xy_color\": [$XY_X, $XY_Y]}"
First version only saved brightness. When I told Claude Code "the lights aren't going back to where they were," it figured out the issue and added the xy_color handling.
The Webhook (Flask)
from flask import Flask, request, jsonify
import subprocess
import json
app = Flask(__name__)
def load_tokens():
with open('/root/webhooks/tokens.json') as f:
return json.load(f)
@app.route('/flash-peter-office-lights')
def flash():
token = request.args.get('auth_token')
if not token:
return jsonify({"error": "Missing auth_token"}), 401
tokens = load_tokens()
if token not in tokens:
return jsonify({"error": "Invalid token"}), 403
# SSH to Pi via Tailscale and run the flash script
cmd = 'ssh -i /root/.ssh/pi_key peter@100.x.x.x "/home/peter/flash_lights.sh"'
subprocess.run(cmd, shell=True, timeout=15)
return jsonify({"status": "flashed", "user": tokens[token]["name"]})
Tokens live in a JSON file:
{
"alice-token-123": {"name": "Alice", "created": "2026-01-05"},
"bob-token-456": {"name": "Bob", "created": "2026-01-05"}
}
Each person gets their own token. Revoke access by deleting their entry.
What's Next
Now that the plumbing exists, I can:
- Different colors for different sources - blue for Slack, green for family texts, red for emergencies
-
Slack slash command -
/flash-peterfor coworkers - iOS Shortcut - one-tap button for my wife
- Rate limiting - prevent abuse
- Logging - who flashed and when
If you want to build something similar, the pieces are: Raspberry Pi (or another device to run HomeAssistant), a cheap VPS with Tailscale on both ends, and some basic Python/bash (in my case, written by Claude).
Top comments (11)
This is really fun.
Something I was going to ask privately but might as well here since you posted it:
Is there a reason that making it a
GETrequest is better (very well could be due to simplicity of how some of the short cuts might work) — but I'd think this type of thing would typically want to be aPOSTorPUTrequest. These methods designed for submitting data (or submitting a state change in this case).There may be a hack of sorts that makes
GETsimpler for all intents and purposes, but if you're not aware of reasons why it needs to be built this way, pivoting toPOSTcould help avoid accidental "reads" turning into "writes" — i.e. you share the URL via Slack and the slackbot crawls the URL to check for a title/open graph image and submits the state change.If it's a
POSTrequest (which could come with an accompanyingGETURL which contains a button to trigger thePOSTsubmission.Just my $.02 if not already considered.
Good call.
TLDR, no good reason to use
GETinstead ofPOSTorPUT. It was honestly just Claude Code taking the path of least resistance. (There's a good lesson in here for me and whoever else is reading this).The scenario of the Slack unfurler flickering my lights is definitely something I want to avoid (annoyance, flaky confidence in the system, etc).
Luckily it was a quick fix. I actually just screenshott'd your comment, gave it to Claude, and now the system requires a
POSTwith the auth token ✨️Thanks for the catch and guidance.
Yeah, one way another this is not a "read" request so
GETwill always be vulnerable to inadvertent triggers more-so compared to a submit-type request. It could be a necessary hack for some reason, but it should be re-visited at least to know why it's done that way.Nice!
I did something similar with my teasmade back in the day, except I made a service on the pi that started an ssh tunnel on boot.
Wow, TIL about Teasmade
Was the Teasmade natively IoT or did you use like a smart plug?
Oh it was an ancient 1980s model :)
We put a pi in it connected to the power switch with a relay so it could turn the kettle part on and off, and you could tweet "make tea" at it and it'd turn on. It didn't entirely work and never turned off though.
The good bit was the radio - I used an old mouse, with the rubber wheel against the tuning control, and the buttons wired to the teasmade controls, so you could hit play and the pi would see it as a mouse click, and tuning back and forward would be seen as a scroll event. Tuning switched playlists of mp3s, and while the scrolling was going on it played a file of FM static noises.
Great work on this, nothing beats fun passion projects with practical application for personal use!
That's dope. Nice ideas! I'm personally looking at figuring out how to use auto-ssh to connect all my home devices to my cheap VPS. The general idea being that I can offload serious compute to my home devices for some things :)
the jump from a simple notification service to an IRL ping system is exactly why building in public is so cool.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.