21. Local dev stack: Docker Compose backend + host-run coach view¶
Date: 2026-06-17
Status¶
Accepted — used for the first real-device end-to-end bring-up (2026-06-17). See local-bench-runbook.
Context¶
Bringing a real wearable online needs the whole backend running and reachable from the device: an MQTT broker, the Bun ingest/WS server, and the coach view. Manually starting mosquitto + server + Vite with the right env each time is error-prone, and the broker must be reachable from the device over Wi-Fi. We want a one-command local stack that mirrors production's shape without its security (field AP + Caddy + real auth).
Two constraints surfaced during the first bring-up:
- The host already runs other local Docker stacks that hold ports 3000–3003, so our server can't
publish on 3000.
- Vite's /live WebSocket proxy does not reliably relay the upgrade when Vite runs inside a container — the
server accepts the upgrade (logs ws open) but the browser hangs at "connecting". A direct WS to the server
container works; the same Vite proxy works when Vite runs on the host (the long-standing Playwright topology).
Decision¶
Provide docker-compose.yml running two services — mosquitto + server — plus
a mounted broker config. The coach view (Vite) runs on the host, not in the stack:
docker compose up -d
cd client && VITE_PROXY_TARGET=http://localhost:3007 bun run dev # http://localhost:5173
- Broker published on
1883(the device reaches it at the Mac's Wi-Fi IP). - Server published on
3007(not 3000 — taken by other host containers) for the host Vite to proxy to; internally the server still listens on 3000. - Broker is anonymous; server runs in isolated-LAN anon mode (
ALLOW_ANONYMOUS_LIVE+ANON_SESSIONS=test). - Pinned images (
eclipse-mosquitto:2,oven/bun:1.3), per the repo's CI/CD docker guardrails.
Amended 2026-08-03 — the two security bullets above no longer describe the stack. The production-readiness audit proved both of them exploitable live on this stack (§4.6, §4.1): an anonymous LAN broker let any host on the Wi-Fi read every child's 10 Hz feed or publish forged telemetry the server accepted and persisted as authoritative, and the
0.0.0.0:3007publish put a named child's live position and location history onecurlaway for anything on the subnet — while §2 of this very ADR tells the operator to disable Wi-Fi client isolation. Neither was an acceptable isolated-LAN trade, because the network this runs on is a home Wi-Fi with guests on it, not an isolated one. Changed in Phase 2: - the broker mountsserver/mosquitto/— the same authenticated config and per-device ACLs as the field broker.deploy/mosquitto/mosquitto.conf(the anonymous one) is deleted../server/mosquitto/dev-provision.shcreates the accounts; without it compose refuses to start. - the server is published on127.0.0.1:3007, and the server itself defaults to a loopback bind whenever anon mode is on (PUBLIC_HOST). The container opts out explicitly, because inside its own namespace the exposure boundary is the publish. - anon mode is scoped to the live pitch: names and bulk history need a real login even here.The consequence the original decision did not state: the coach view is now this-machine-only. A second tablet on the Wi-Fi cannot reach it. That is the correct posture for a stack with no login, and a pitch-side tablet is the Caddy + real-auth deployment instead.
The device side needs one firmware edit for the bench — MQTT_HOST → the Mac's Wi-Fi IP (field default stays
192.168.4.1) — and the device + Mac must share the Wi-Fi (a wired dock Ethernet was isolated from the Wi-Fi).
Consequences¶
- + One command brings up the device-facing backend; mirrors the production pipeline shape; reproducible.
- + Keeps the working WS path (Vite on host) rather than shipping a broken in-container coach view.
- − The coach view isn't containerized — a deliberate split (the in-container Vite WS proxy limitation).
- −
MQTT_HOSTis compiled into the firmware (no NVS/env override), so the bench needs a one-line edit + reflash; and the Mac's DHCP Wi-Fi IP can change. - Security: ~~anonymous broker + anon live mode are acceptable ONLY on an isolated LAN~~ — see the amendment above; both were closed in Phase 2 after being demonstrated against this stack. Anon live mode survives, now loopback-bound and scoped to the pitch. Never internet-exposed. Production keeps the field AP + per-device MQTT auth + cookie auth (ADR-0007, ADR-0008, ADR-0013, ADR-0015).
Alternatives considered¶
- Coach view in the compose stack too — rejected: the in-container Vite
/liveWS proxy hangs (see Context). - Broker native (brew) instead of containerized — viable, but Compose keeps broker + server lifecycle in one place; the broker-in-Docker published port IS reachable from the LAN device (verified), so no need.
- Map the server to host 3000 — rejected: held by other local containers; used 3007.
- A phone hotspot to avoid Wi-Fi isolation — kept as a fallback in the runbook when the router isolates clients.