From 4fc7893d28614baede48a32b81ddf61e7307adc1 Mon Sep 17 00:00:00 2001 From: Mannu Date: Thu, 11 Jun 2026 10:26:45 +0530 Subject: [PATCH] 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 --- ha-proxy/docker-compose.yml | 50 +++++++++++++++++++++++ ha-proxy/nginx.conf | 47 +++++++++++++++++++++ home-assistant/README.md | 34 +++++++++++++++ home-assistant/configuration.snippet.yaml | 18 ++++++++ home-assistant/docker-compose.yml | 46 +++++++++++++++++++++ 5 files changed, 195 insertions(+) create mode 100644 ha-proxy/docker-compose.yml create mode 100644 ha-proxy/nginx.conf create mode 100644 home-assistant/README.md create mode 100644 home-assistant/configuration.snippet.yaml create mode 100644 home-assistant/docker-compose.yml diff --git a/ha-proxy/docker-compose.yml b/ha-proxy/docker-compose.yml new file mode 100644 index 0000000..e8c942d --- /dev/null +++ b/ha-proxy/docker-compose.yml @@ -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 diff --git a/ha-proxy/nginx.conf b/ha-proxy/nginx.conf new file mode 100644 index 0000000..8eaf3d7 --- /dev/null +++ b/ha-proxy/nginx.conf @@ -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; + } + } +} diff --git a/home-assistant/README.md b/home-assistant/README.md new file mode 100644 index 0000000..74d46c0 --- /dev/null +++ b/home-assistant/README.md @@ -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://: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. diff --git a/home-assistant/configuration.snippet.yaml b/home-assistant/configuration.snippet.yaml new file mode 100644 index 0000000..7c18a2c --- /dev/null +++ b/home-assistant/configuration.snippet.yaml @@ -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 diff --git a/home-assistant/docker-compose.yml b/home-assistant/docker-compose.yml new file mode 100644 index 0000000..f779b22 --- /dev/null +++ b/home-assistant/docker-compose.yml @@ -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