6.8 KiB
ODS reverse proxy (ods-proxy)
The single LAN-facing entry that makes http://<device>.local (no port) actually work, with per-service subdomains for chat, dashboard, ODS Talk, auth, and hermes.
Without this extension, ODS's services bind to 127.0.0.1 by default — they're reachable from the host but not from another device on the LAN. A phone scanning a "browse to http://ods.local" QR code hits port 80 on the device, finds nothing, gives up. The dashboard's promise of <device>.local as a one-tap entry was broken before this extension.
With it, port 80 becomes the single entry point. Caddy answers each subdomain on the device's mDNS hostname and forwards to the right backend, root-mounted — no subpath gymnastics.
<device>.local → 302 → chat.<device>.local
chat.<device>.local → Open WebUI (port 3000)
dashboard.<device>.local → ODS Dashboard (port 3001)
talk.<device>.local → ODS Talk mobile UI (port 3001)
auth.<device>.local → dashboard-api (port 3002, magic-link redemption)
api.<device>.local → dashboard-api (port 3002, admin /api/*)
hermes.<device>.local → hermes-proxy (port 9120, when enabled)
/health → Caddy itself ("ok") — served on every host for easy probing
Each subdomain needs a LAN name record pointing at the ODS device. The companion ods-mdns service handles this automatically when enabled; until then, add equivalent DNS/hosts records for the subdomains you want to use. Other ODS services keep their loopback bindings. The proxy is the only thing that opens up to the LAN.
Why host-based and not path-based
An earlier draft routed paths off the bare hostname (<device>.local/chat, <device>.local/api/, etc.). That broke because each backend was already coded to live at the root:
- Open WebUI's static assets and websocket endpoints assume root mounting; mounting under
/chatproduced broken root-relative paths and dead websockets. - React Router base-href games would have been required for the dashboard.
- Open WebUI's OAuth callback URLs ignore proxy subpath rewriting and would have leaked back to the bare hostname.
Host-based routing sidesteps all of it. Every backend stays at /, the proxy just terminates the Host header.
Cookie scope (and why this matters for magic links)
dashboard-api sets the ods-session cookie on auth.<device>.local during magic-link redemption with Domain=<device>.local. That makes the cookie visible to every subdomain: chat, hermes, dashboard, api. So a single redemption authenticates the user across all of them, even though each subdomain is a different origin.
The cookie's Domain is controlled by the ODS_COOKIE_DOMAIN env var in dashboard-api. Default empty (host-only); the installer sets it to <device>.local (the value of ODS_DEVICE_NAME + .local) so SSO works out of the box.
When to enable it
- Yes if you want to reach ODS from a phone / laptop on the same network at
http://<device>.local. - Yes if you're using Tailscale (PR-12) — the proxy becomes the single endpoint exposed on the tailnet too.
- No if ODS is single-user / localhost-only — you save a small process and a port binding.
ods enable ods-proxy
# Test (substitute <device> with your ODS_DEVICE_NAME, default "ods"):
curl http://chat.<device>.local/ # → Open WebUI
curl http://dashboard.<device>.local/ # → ODS Dashboard
curl http://talk.<device>.local/talk # -> ODS Talk
curl http://auth.<device>.local/health # → ok (Caddy)
Prerequisites
For the bare URL to actually load anything, two host-level conditions must hold:
ODS_PROXY_BIND=0.0.0.0in.env, or left unset so the proxy's default applies. The proxy listens on the LAN interface; withODS_PROXY_BIND=127.0.0.1, the LAN can't reach it. Do not set globalBIND_ADDRESS=0.0.0.0just for this — that exposes every service instead of only the proxy.- mDNS, DNS, or hosts-file records publish the per-service subdomains. The companion
ods-mdnsservice handles this automatically when enabled; without it, create equivalent records manually.
The installer's first-boot flow handles both. If you're not using the installer, leave ODS_PROXY_BIND at its default or set it explicitly, then run ods enable ods-proxy manually.
Security posture
The proxy is the trusted gate. Behind it, each service's own auth applies:
dashboard-api: API key (DASHBOARD_API_KEY)- Open WebUI: its own auth (
WEBUI_AUTH=trueby default — users sign up / sign in) - Dashboard SPA: the React app shows admin features only when the API call succeeds
- ODS Talk: signed
ods-sessioncookie from owner-card redemption; no dashboard admin API control hermes-proxy: Caddyforward_authagainstdashboard-api/api/auth/verify-session(signed-cookie check)
The proxy itself adds NO auth layer. Adding one here would duplicate without strengthening.
Trust model:
- Trusted LAN: a home network where everyone on the network is in the household. Exposing the proxy on the LAN is fine.
- Tailscale: also fine — Tailscale's identity-based access is its own auth layer.
- Public internet: ❌ NEVER. Don't publish port 80 to the public internet without an additional auth/TLS layer.
TLS
HTTP only in v1. Adding HTTPS needs one of:
- Tailscale-issued certs —
tailscale cert <hostname>.<tailnet>.ts.netproduces a real Let's-Encrypt cert; Caddy can serve it directly. Documented as a follow-up. - Self-signed cert + device trust — operator generates a cert, distributes the CA to family devices.
- Caddy's auto-https for public domains — only works if you have a real DNS name. Not the
.localcase.
For now, plain HTTP on the trusted LAN. The cookie-issuing flows that set Secure= honor the request scheme — they'll set the Secure flag once TLS is in front.
How to bypass the proxy
ods disable ods-proxy stops the container. Each backend service goes back to being only reachable on its individual port (<host-ip>:3000, <host-ip>:3001, etc.) — and only if BIND_ADDRESS=0.0.0.0 is set globally. Otherwise they stay loopback.
If you want LAN access to a single specific service without the proxy, add a ports: binding to that service's compose file (or set BIND_ADDRESS=0.0.0.0 globally — but that exposes ALL services, the security tradeoff this whole extension was designed to avoid).
Bump history
| Date | Pinned Caddy | Notes |
|---|---|---|
| 2026-05-12 | caddy:2.8.4-alpine |
Initial integration. HTTP only; TLS deferred. |
| 2026-05-12 | caddy:2.8.4-alpine |
Switched from path-based (/chat, /api/*) to host-based (chat.<device>.local, etc.) routing after audit on subpath issues. Cookie domain via ODS_COOKIE_DOMAIN. |
| 2026-05-15 | caddy:2.11.3-alpine |
Updated ODS Proxy image to the current Caddy 2.x stable pin. |