Add Home Assistant (home box) + ha-proxy (Hetzner/Dokploy) stacks

- home-assistant/: HA Core + matter-server, host networking (home box, not Dokploy)
- ha-proxy/: nginx reverse-proxy, ha.manohargupta.com -> home HA over Tailscale
- dual-homed (dokploy-network ingress + bridge egress), mirrors n8n pattern
This commit is contained in:
Manohar Gupta 2026-06-11 10:26:45 +05:30
parent d868499b42
commit 4fc7893d28
5 changed files with 195 additions and 0 deletions

View file

@ -0,0 +1,50 @@
# ============================================================================
# ha-proxy -- ha.manohargupta.com (runs on HETZNER via DOKPLOY)
# ----------------------------------------------------------------------------
# Home Assistant runs at HOME. This stack is ONLY a reverse-proxy bridge:
#
# Browser --TLS--> Traefik (Hetzner) --> ha-proxy (nginx) --tailnet--> HA @ home
#
# WHY nginx and not a plain Traefik route to the home IP:
# - Traefik (Dokploy's) sits on the dokploy-network swarm OVERLAY, which can't
# cleanly egress to a tailnet peer. nginx here is dual-homed: it takes
# ingress from Traefik on dokploy-network, and egresses to the home box
# over a local BRIDGE network (ha_egress) whose gateway is the Hetzner host
# -- the host then routes to tailscale0. This is the same dokploy-network +
# bridge pattern your n8n stack already uses successfully.
# - nginx also handles the WebSocket upgrade HA's frontend depends on.
#
# RAM cost on Hetzner: ~10-15 MB (nginx:alpine). The heavy part stays home.
# ============================================================================
services:
ha-proxy:
image: nginx:1.27-alpine
restart: unless-stopped
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
networks:
- dokploy-network # ingress: Traefik routes ha.manohargupta.com here
- ha_egress # egress: container -> host -> tailscale0 -> home box
# --- Container-level labels (docker provider) ---
labels:
- "traefik.enable=true"
- "traefik.docker.network=dokploy-network"
- "traefik.http.routers.ha.rule=Host(`ha.manohargupta.com`)"
- "traefik.http.routers.ha.entrypoints=websecure"
- "traefik.http.routers.ha.tls.certresolver=letsencrypt"
- "traefik.http.services.ha.loadbalancer.server.port=80"
# --- Service-level labels (swarm provider) -- Dokploy deploys as swarm stack ---
deploy:
labels:
- "traefik.enable=true"
- "traefik.docker.network=dokploy-network"
- "traefik.http.routers.ha.rule=Host(`ha.manohargupta.com`)"
- "traefik.http.routers.ha.entrypoints=websecure"
- "traefik.http.routers.ha.tls.certresolver=letsencrypt"
- "traefik.http.services.ha.loadbalancer.server.port=80"
networks:
dokploy-network:
external: true
ha_egress:
driver: bridge

47
ha-proxy/nginx.conf Normal file
View file

@ -0,0 +1,47 @@
# nginx.conf -- ha-proxy (Hetzner, behind Traefik)
# Forwards ha.manohargupta.com -> Home Assistant on the home box over Tailscale.
# Traefik terminates TLS; this listens plain HTTP on :80 inside the network.
worker_processes 1;
events { worker_connections 256; }
http {
# --- WebSocket upgrade plumbing -------------------------------------
# HA's frontend uses a persistent WebSocket (/api/websocket). Without this
# map the UI loads then hangs "Connecting...". The map sets the Connection
# header to "upgrade" only when the client requested an upgrade.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# !!! EDIT THIS: the home box's TAILSCALE IP (100.x.y.z), port 8123 !!!
# Find it after the home box joins your tailnet: `tailscale ip -4` on that box.
upstream homeassistant {
server 100.XX.XX.XX:8123;
}
server {
listen 80;
server_name ha.manohargupta.com;
location / {
proxy_pass http://homeassistant;
proxy_http_version 1.1;
# WebSocket upgrade
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Preserve host + client info so HA's trusted_proxies check passes
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# HA long-lived connections: don't cut them off early
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
}

34
home-assistant/README.md Normal file
View file

@ -0,0 +1,34 @@
# Home Assistant — ha.manohargupta.com
HA runs **at home**; Hetzner only proxies the subdomain. Two deploy targets:
- `home-assistant/` → HOME BOX (Pi/N100), plain `docker compose up -d`. NOT Dokploy.
- `ha-proxy/` → HETZNER via Dokploy (nginx reverse-proxy over Tailscale).
## Deploy order
1. **Home box**: `docker compose up -d` → onboard HA at `http://<lan-ip>:8123`
paste `configuration.snippet.yaml` into `config/configuration.yaml` → restart.
2. Join home box to Tailscale; `tailscale ip -4` → note the 100.x IP.
`ufw allow in on tailscale0 to any port 8123` on the home box.
3. **DNS**: `ha` A-record → `77.42.82.225` (done).
4. **Hetzner**: set the home tailnet IP in `ha-proxy/nginx.conf`, deploy `ha-proxy`
as a Dokploy Compose app.
## Dead-man's-switch — verify BEFORE trusting the cert flow
From inside the running ha-proxy container:
```
wget -qO- http://100.XX.XX.XX:8123 | head -c 200
```
HTML back → good. Hang/refused → host isn't forwarding container→tailnet; check
`ip route get 100.XX.XX.XX` resolves via tailscale0 + Docker bridge MASQUERADE.
## Why this shape
- HA needs `network_mode: host` (mDNS/Matter multicast + LAN RTSP for Tapo).
- An HA instance in Helsinki cannot reach home-LAN cameras — hence home box.
- ha-proxy is dual-homed (dokploy-network ingress + bridge egress), same pattern
as the n8n stack, because the swarm overlay can't egress to a tailnet peer.
## Security (flagged)
Public subdomain = HA login is internet-facing. Enable HA 2FA; consider Authentik
forward-auth later. Tailnet-only access (skip the public route) is safer if you
only reach HA from your own devices.

View file

@ -0,0 +1,18 @@
# configuration.yaml SNIPPET -- paste into ./config/configuration.yaml on the HOME box
# ----------------------------------------------------------------------------
# WHY this is required:
# HA refuses proxied requests by default (anti-spoofing). When ha-proxy forwards
# from Hetzner over the tailnet, the request reaches HA with a SOURCE IP equal to
# the Hetzner node's tailnet IP (after the host masquerades it out tailscale0).
# You must whitelist that IP, or every page load fails with HTTP 400
# "received from untrusted proxy / IP address not allowed".
#
# If you hit a 400, check the HA log -- it prints the exact rejected IP. Put THAT
# IP in trusted_proxies (it should be 100.75.128.45, your Hetzner tailnet IP).
http:
use_x_forwarded_for: true
trusted_proxies:
- 100.75.128.45 # Hetzner (manohar-ubuntu) tailnet IP = the proxy's source
- 127.0.0.1
- ::1

View file

@ -0,0 +1,46 @@
# ============================================================================
# Home Assistant + Matter Server -- runs on the HOME BOX (Pi 5 / N100)
# ----------------------------------------------------------------------------
# THIS DOES NOT RUN ON HETZNER / DOKPLOY.
# It lives in the infra repo for source-control + documentation, but it is
# deployed by hand on the home machine: `docker compose up -d`
#
# WHY host networking (network_mode: host) is mandatory here:
# - Tapo cameras are reached over your LAN (RTSP 554 / ONVIF 2020). HA must
# sit on the same L2 segment to discover + stream them.
# - mDNS / SSDP / Matter commissioning are MULTICAST. Bridge networking
# drops multicast at the container boundary, so discovery silently fails.
# With host mode the container shares the home box's network stack directly.
# ============================================================================
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:stable
container_name: homeassistant
restart: unless-stopped
network_mode: host # see header note -- non-negotiable for HA
volumes:
- ./config:/config # all HA state + configuration.yaml lives here
- /etc/localtime:/etc/localtime:ro
- /run/dbus:/run/dbus:ro # lets HA see host Bluetooth/dbus (harmless if unused)
environment:
- TZ=Asia/Kolkata
# NOTE: no `ports:` needed -- host mode already exposes :8123 on the box.
# NOTE: no `privileged` -- only required if you later pass a USB Zigbee/Thread
# dongle, which would also need a `devices:` mapping. Not needed for
# WiFi / RTSP / Matter-over-WiFi devices.
# --- Matter controller (you mentioned Matter devices) -------------------
# HA talks to this over ws://localhost:5580 (add the "Matter" integration in
# the HA UI and point it there). Comment this whole block out if you want to
# hold off on Matter for now -- Tapo cameras do NOT need it.
matter-server:
image: ghcr.io/home-assistant-libs/python-matter-server:stable
container_name: matter-server
restart: unless-stopped
network_mode: host # Matter commissioning needs multicast too
security_opt:
- apparmor=unconfined # required for the Matter stack's raw network access
volumes:
- ./matter-data:/data
environment:
- TZ=Asia/Kolkata