Setting up a public URL that flashes my office lights
Source: Dev.to
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.
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]}"
The first version only saved brightness. When I told Claude Code “the lights aren’t going back to where they were,” it 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: a Raspberry Pi (or another device running Home Assistant), a cheap VPS with Tailscale on both ends, and some basic Python/Bash (in my case, written by Claude).