Home Assistant deployment on UGREEN NAS with dedicated Cloudflare tunnel.
URL: home.pranavprem.com
Internet β Cloudflare Tunnel β ha-cloudflared β Home Assistant / Grocy
The stack now follows a mediaserver-style Docker network layout. Cloudflared, Home Assistant, and Grocy share a proxy network so the tunnel can route directly to containers by service name. Home Assistant, Grocy, HomeButler, Mosquitto, and Govee2MQTT share an automation network for internal service-to-service traffic. HomeButler binds to 127.0.0.1 by default, but you can bind it to the NAS LAN IP for trusted LAN-only access.
| Container | Image | Purpose |
|---|---|---|
homeassistant |
ghcr.io/home-assistant/home-assistant:stable |
Smart home hub |
grocy |
lscr.io/linuxserver/grocy:latest |
Household food data layer (pantry, shopping list, meal state) |
homebutler |
local build (./services/homebutler) |
Internal Grocy API + local control layer for container health/restarts |
mosquitto |
eclipse-mosquitto:2 |
MQTT broker on the internal automation network |
govee2mqtt |
ghcr.io/wez/govee2mqtt:latest |
Govee device bridge via MQTT |
ha-cloudflared |
cloudflare/cloudflared:latest |
Dedicated tunnel for external access |
Run this on the NAS:
make bootstrapThe bootstrap flow now:
- creates or updates
.env - prompts for the basics it cannot safely guess
- starts Home Assistant, Grocy, HomeButler, and the existing local services
- triggers Grocy first-run init
- auto-creates a Grocy API key for HomeButler when needed
- installs a Home Assistant package scaffold into your HA config path
- reuses the existing Cloudflare tunnel config if you already have one
-
Copy
example.envto.envand fill in values:cp example.env .env
-
Configure
.env:HA_CONFIG_PATHβ path to HA config directory on NASTZ,PUID,PGIDβ shared container settingsHOMEASSISTANT_PORTβ Home Assistant host port (default8123)PROXY_SUBNETβ stable Docker subnet for theproxynetwork (default172.31.240.0/24)AUTOMATION_SUBNETβ stable Docker subnet for theautomationnetwork (default172.31.241.0/24)GROCY_PORTβ Grocy host port (default9283)HOMEBUTLER_BIND_IPβ host IP to publish HomeButler on. Leave at127.0.0.1; HomeButler is unauthenticated and hasdocker.sockmounted, so widening this is root-equivalent LAN exposure (see step 9)HOMEBUTLER_PORTβ HomeButler API port (default8000)CLOUDFLARED_TOKENβ token for the already-existing HA tunnel
-
Deploy:
make up
make upnow starts the stack, then runsscripts/sync_trusted_proxies.pyto patchconfiguration.yamlwith the currently detected trusted proxies and restart Home Assistant if the config changed. -
Add to
configuration.yamlfor Cloudflare proxy support:http: use_x_forwarded_for: true trusted_proxies: - 127.0.0.1 - ::1 - 172.31.240.0/24
Use the same CIDR as
PROXY_SUBNETin.env, not a single container IP. That makes the setup self-healing when theha-cloudflaredcontainer is recreated and gets a different address. -
If your HA MQTT integration was previously pointed at
localhost, change it to broker hostmosquitto.
Run these on the NAS from the repo root.
-
Pull the latest repo state:
git pull
-
Create
.envif this is a fresh deploy:cp example.env .env
-
Edit
.envand set at minimum:HA_CONFIG_PATH=/volume1/media/config/homeassistant-config TZ=America/Los_Angeles PUID=1000 PGID=1000 HOMEASSISTANT_PORT=8123 PROXY_SUBNET=172.31.240.0/24 AUTOMATION_SUBNET=172.31.241.0/24 GROCY_PORT=9283 HOMEBUTLER_BIND_IP=127.0.0.1 HOMEBUTLER_PORT=8000 CLOUDFLARED_TOKEN=...your tunnel token...
-
Bootstrap or deploy:
make bootstrap
If you do not want the interactive bootstrap flow, use:
make up
-
In Home Assistant
configuration.yaml, make sure Cloudflare proxy support is present:http: use_x_forwarded_for: true trusted_proxies: - 127.0.0.1 - ::1 - 172.31.240.0/24
Keep that CIDR aligned with
PROXY_SUBNETin.env. Do not pin a singleha-cloudflaredIP. -
If your HA MQTT integration currently uses
localhost, change it to broker hostmosquitto. -
In Cloudflare Zero Trust, update the tunnel public hostnames for this stack:
home.pranavprem.comβ HTTP βhomeassistantβ port8123grocy.pranavprem.comβ HTTP βgrocyβ port80
Important: these service names are resolved from inside the
ha-cloudflaredcontainer on the Dockerproxynetwork. They are meant to hit the containers directly, not NAS localhost. -
Restart the stack if you changed
.envor tunnel config:docker compose --env-file .env up -d
If the Docker networks already exist with old auto-assigned subnets, recreate them once so the fixed CIDRs take effect:
docker compose --env-file .env down docker compose --env-file .env up -d
If
make upfails withPool overlaps with other one on this address space, set unused CIDRs in.envbefore retrying. The current safe defaults in this repo are:PROXY_SUBNET=172.31.240.0/24 AUTOMATION_SUBNET=172.31.241.0/24
-
Verify locally on the NAS:
docker compose ps curl -I http://127.0.0.1:8123 curl -I http://127.0.0.1:9283 curl http://${HOMEBUTLER_BIND_IP:-127.0.0.1}:${HOMEBUTLER_PORT:-8000}/health
Keep
HOMEBUTLER_BIND_IP=127.0.0.1. HomeButler has/var/run/docker.sockmounted and no authentication on its control routes, so anything that can reach its port can restart any registered container and run allowlistedmake/docker composeactions against thehomeassistant,mediaserver,morpheus, andtorrepos. That is root-equivalent on the NAS. Binding it to the LAN address hands that to every device on the WiFi, including the IoT devices sharing that subnet.Neo does not need the LAN binding. Reach HomeButler through Home Assistant instead:
packages/food_stack.yaml.templateexposes every registry restart and allowlisted action asrest_command+scriptentities, and HA talks tohttp://homebutler:8000over the internalautomationnetwork. Neo drives HA (which is authenticated) via HA MCP, and HA drives HomeButler. See "HomeButler control plane" below.If you ever genuinely need direct LAN access, treat it as a change that requires auth on
/ops/*first β not just a narrower bind IP. Firewall rules alone leave every device on the trusted subnet with full control. -
Verify externally:
- open
https://home.pranavprem.com - open
https://grocy.pranavprem.com
The first fix for Cloudflared IP churn is to trust the proxy network subnet, not the current container IP. This repo now pins the proxy network to PROXY_SUBNET and the automation network to AUTOMATION_SUBNET, so container restarts do not change the trusted CIDR.
Synology and some Docker host setups can still surface forwarded traffic from a host-side IP instead of the container IP. For that case, this repo also ships scripts/sync_trusted_proxies.py, which:
- reads
PROXY_SUBNETfrom.env - detects the host's current LAN IP
- reads any recent
Received X-Forwarded-For header from an untrusted proxy ...IPs from Home Assistant logs - reads the current
ha-cloudflaredcontainer IP when available - patches
HA_CONFIG_PATH/configuration.yaml - optionally restarts Home Assistant if the trusted proxy list changed
Use it directly with:
make sync-trusted-proxiesmake up already runs that script after docker compose up --build -d, so a normal deploy will self-heal the http.trusted_proxies block when possible.
This stack uses its own dedicated tunnel (separate from the mediaserver tunnel).
- Dashboard: Cloudflare Zero Trust β Networks β Tunnels
- Route for Home Assistant:
home.pranavprem.comβhttp://homeassistant:8123 - Route for Grocy:
grocy.pranavprem.comβhttp://grocy:80
Those routes hit the Docker containers directly, not the NAS localhost port.
Tesla integration requires a public key hosted at:
https://home.pranavprem.com/.well-known/appspecific/com.tesla.3p.public-key.pem
This is served via a Cloudflare Worker (tesla-public-key) on the home.pranavprem.com route, since HA doesn't natively serve /.well-known/ paths.
The food-decision stack now lives here:
- Grocy stores pantry, shopping list, and meal-related state
- Home Assistant is the orchestration and Google Home bridge
- HomeButler runs inside this NAS stack as an internal API and control layer
- Neo is the reasoning layer for meal decisions
- HA MCP lives with Neo on the Mac mini so Neo can drive Home Assistant cleanly
See FOOD_SYSTEM.md for the full architecture.
HomeButler binds to 127.0.0.1:8000 on the NAS by default, which makes it a
localhost-only control plane, and should stay that way β see step 9 in the deploy
steps for why. Direct live Grocy writes from Neo are intentionally blocked, so
migration bundles are applied through HomeButler instead:
POST http://127.0.0.1:8000/migration/grocy/apply
Content-Type: application/json
The JSON body is a declarative bundle with any of:
quantity_units, locations, shopping_locations, task_categories,
chores, tasks, equipment, products, recipes, meal_plan, and an
optional meta block. Cross-section references use names (e.g. a product's
location matches a location's name); HomeButler resolves them to Grocy
ids at apply time.
The apply is idempotent: named objects match by normalized name, recipe
ingredients match by (recipe, product), meal-plan entries match by
(day, type, note, recipe|product), and stock_amount only tops up the
delta against current on-hand stock. Re-running the same bundle after it
has already been applied is a safe no-op.
For the current rollout, the repo includes a checked-in bundle at
migrations/grocy/2026-04-21-homebutler.json plus a helper script and make
wrapper:
make apply-grocy-migrationThat target posts the checked-in bundle to http://127.0.0.1:${HOMEBUTLER_PORT}/migration/grocy/apply using the port from .env, so you do not need to hand-craft the curl request on the NAS. Run it on the NAS rather than exposing the route to the LAN.
HomeButler exposes a typed control plane over the containers it manages:
GET /ops/stacksβ list all registered stacks (homeassistant,mediaserver,morpheus,tor) with their services, container state, and resolved repo paths.GET /ops/stacks/{stack}/services/{service}β inspect a single service.GET .../logs,POST .../restartβ per-service log/restart via the registry.GET /ops/actionsβ list allowlisted higher-level actions (e.g.mediaserver.update_gluetun,morpheus.redeploy,tor.restart) with availability metadata.POST /ops/actions/{action}/runβ execute an allowlisted action.
The Home Assistant package template exposes this control plane as
rest_command + script entities so Neo can call HomeButler through HA's
internal Docker network without publishing HomeButler directly on the LAN.
packages/food_stack.yaml.template currently includes restart scripts for
every service in the registry plus scripts for every allowlisted action. The
old shortcut script names for Grocy, Home Assistant, and Cloudflared remain as
compatibility aliases.
The registry is code-reviewed in services/homebutler/app/registry/ β no
runtime/shell input, no user-supplied make targets, no user-supplied
container names. The legacy /ops/containers* routes remain for
compatibility. See services/homebutler/docs/control-plane-design.md for
the full design.
Actions need both the host repo bind-mounted into the container (see
HOMEBUTLER_*_REPO_HOST in example.env) and the required executables
(make, docker) in the HomeButler image. Until both are in place an
action is returned with available: false and a human-readable reason
rather than being hidden or silently failing.
| Integration | Devices | Type |
|---|---|---|
| Philips Hue | Lights | Local (bridge) |
| Roborock | Robot vacuums | Cloud |
| Google Nest | Doorbell, cameras, speakers, thermostat | Cloud |
| LG ThinQ | Washer, dryer, fridge, dishwasher, TV | Cloud |
| SwitchBot | Curtains | Cloud/BLE |
| Tesla Fleet | Model 3 + Wall Connector | Cloud |
| Eight Sleep (HACS) | Pod | Cloud |
| Dreo | Fan/heater | Cloud |
| Govee | Light bar (H607C) | Cloud + LAN via govee2mqtt |
| Apple TV | Media player | Local |
| Canon Printer | Printer | Local (IPP) |
| Chromecast | Projector | Local |
- Konnected blaQ β garage door (hardware on order; currently using ratgdo)
- Levoit / VeSync β 4x Levoit Core 200S air purifiers (WiFi enabled)
- Non-Hue Philips light β TBD
The main dashboard ("Agraharam") uses Mushroom cards from HACS. Configuration is in dashboard.yaml.
25 automations in automations/ covering lights, garage, vacuum, laundry, Tesla charging, movie mode, air purifiers, and more. See CLAUDE.md for the full list.
privileged: falseβ no unnecessary host accessno-new-privileges: trueon all containers- Cloudflare tunnel β no ports exposed on router
- HA has strong password + MFA enabled
- Tesla Fleet API scoped via OAuth
NAS
βββ proxy network: homeassistant, grocy, ha-cloudflared
βββ automation network: homeassistant, grocy, homebutler, mosquitto, govee2mqtt
βββ homeassistant published on :8123
βββ grocy published on :9283
βββ homebutler published on ${HOMEBUTLER_BIND_IP:-127.0.0.1}:${HOMEBUTLER_PORT:-8000}
This keeps Cloudflare routing direct-to-container instead of bouncing through NAS localhost. Tradeoff: integrations that previously relied on host-network discovery may need manual reconfiguration or static hosts after the move.