Two hosts, three subnets, one edge.
BawtHub runs on home hardware — a residential Spectrum connection in front of two physical hosts on the LAN. Public traffic terminates at openresty, gets routed by Nginx Proxy Manager, gates through Authelia for anything sensitive, and lands on either the BawtHub frontend on echo or a service container on Unraid. There's no Kubernetes, no overlay network spanning hosts — just Docker bridge networks, br0/br0.2 ipvlan adapters, and an SSH-reachable boundary between the two boxes.
01 Two physical hosts.
Everything runs on two machines:
| Host | Role | LAN IP | What lives here |
|---|---|---|---|
echo | Workstation / compute | 10.0.0.101 | BawtHub frontend + backend, llm-bawt-app (FastAPI + GPU), all three agent bridges, Redis, the moshi STT/TTS pair, traefik, the playwright MCP, crawl4ai, nocodb |
unraid | NAS / always-on services | 10.0.0.99 (mgmt) + 10.0.2.x (br0.2) | Postgres (pgvector), Nginx Proxy Manager, Authelia, OAuth2 Proxy, AdGuard Home, Home Assistant, the bawthub-public-site nginx, all the *arr/media stack, Vaultwarden, etc. |
echo is the compute box — it has the GPU for vLLM/llama.cpp + the moshi voice pipeline, the agent SDKs, and the live-reloading frontend. Unraid is the everything-else box: it holds the storage array, runs the long-lived infrastructure services, and is where the reverse proxy + auth stack live. Postgres lives on Unraid so that even if echo reboots, the database (which both echo and BawtHub depend on) stays up.
02 Three Docker networks (per host).
There's no overlay network connecting the two hosts. Docker bridges on each host stay local; cross-host traffic goes over the LAN by IP.
| Network | Host | Driver | Subnet | Used by |
|---|---|---|---|---|
br0 | unraid | ipvlan | 10.0.0.0/24 (LAN) | Containers that need a LAN IP indistinguishable from physical hosts (legacy) |
br0.2 | unraid | ipvlan | 10.0.2.0/24 (VLAN 2 — services) | NPM (10.0.2.2), Authelia (10.0.2.99), bawthub-public-site (10.0.2.36), Postgres, most of the rest |
bridge | unraid | bridge | 172.17.0.0/16 | Containers that don't need a routable IP (vaultwarden, etc.) |
bawthub_default | echo | bridge | 172.19.0.0/16 | traefik, frontend, backend, tts, stt, nocodb |
llm-bawt_default | echo | bridge | 172.18.0.0/16 | llm-bawt-app, redis, all three bridges, playwright-mcp, crawl4ai |
The two compose projects on echo (bawthub and llm-bawt) deliberately stay on separate bridge networks. They communicate through host.docker.internal:8642 instead of a shared network — every compose service includes extra_hosts: host.docker.internal:host-gateway for exactly this purpose. The benefit is that either stack can be torn down and recreated without affecting the other.
03 Edge topology.
app.bawthub.combawthub.comecho.ferreri.us→ 204.210.248.21610.0.2.2per-host .conf filesLet's Encrypt via Porkbun DNS-0110.0.2.99:90912FA + SSOauth.bawthub.com portal10.0.0.101:80 (traefik)bawthub-public-site @ 10.0.2.36:80Various Unraid services04 Hostnames and what they route to.
The naming model has two flavors. Internal LAN hostnames live under *.lan.ferreri.us and *.lan.zenoran.com and resolve only inside the LAN via AdGuard Home rewrites. Public hostnames live at the bare domains and resolve to 204.210.248.216 via Porkbun.
| Hostname | Visibility | Auth? | Routes to |
|---|---|---|---|
bawthub.com / www.bawthub.com | Public | None | bawthub-public-site @ 10.0.2.36:80 (nginx:alpine serving this very site) |
app.bawthub.com | Public | Authelia | echo @ 10.0.0.101:80 → traefik → bawthub-frontend |
auth.bawthub.com | Public | (it is the auth portal) | Authelia @ 10.0.2.99:9091 |
echo.ferreri.us / echo.zenoran.com | Public | Authelia | echo @ 10.0.0.101:80 (same as app.bawthub.com; traefik fans out by Host header) |
echo.lan.ferreri.us / echo.lan.zenoran.com | LAN only | None (LAN-trusted) | Same — but resolved by AdGuard, bypassing the public path entirely |
dev.echo.lan.* | LAN only | None | Same container — legacy alias kept working |
snapshot.echo.lan.* | LAN only | None | Opt-in baked snapshot frontend (compose profile snapshot) — for A/B compares |
vault.ferreri.us, plex.ferreri.us, etc. | Public | Authelia (most) | Direct container on Unraid (per NPM proxy host) |
*.lan.ferreri.us / *.lan.zenoran.com are internal-only. They use real, paid TLDs (so wildcard TLS works without a self-signed CA), but DNS resolution happens at AdGuard Home which intercepts and rewrites these to LAN IPs. Public DNS has no record for them. The non-.lan. bare-domain variants (echo.ferreri.us) resolve publicly through 204.210.248.216 and gate through the full NPM + Authelia stack. Same underlying service, two paths to it, different trust levels.
05 Echo: the compute box.
echo runs two compose projects (bawthub and llm-bawt) plus a few standalone containers. The currently-running set:
| Container | Image | Purpose |
|---|---|---|
bawthub-traefik-1 | traefik:v3.6.14 | HTTP fan-out by Host header. Catches every public + LAN hostname for the frontend container. |
bawthub-frontend-1 | bawthub-frontend:latest | Next.js 15 app with hot-reload (frontend/ bind-mounted). This container is the deploy — edit a file, HMR picks it up, users see it. |
bawthub-backend-1 | bawthub-backend:latest | Python FastAPI backend that owns the voice pipeline and a few /api/* routes that aren't frontend-owned. Bound to /api with priority=100 as a catch-all. |
bawthub-tts-1 / bawthub-stt-1 | moshi-server-prebuilt | GPU-pinned moshi voice pipeline — TTS on port 8089, STT on 8090. |
llm-bawt-app | llm-bawt-app | FastAPI service on :8642, MCP server on :8001. Holds the bot configs, memory pipeline, and chat-completion router. |
llm-bawt-redis | redis:7-alpine | Event bus for the bridges. maxmemory 128mb, allkeys-lru. |
llm-bawt-openclaw-bridge | llm-bawt-bridge | WebSocket client to the remote OpenClaw gateway. Translates upstream events to AgentEvents. |
llm-bawt-claude-code-bridge | llm-bawt-bridge | Spawns the Claude Code SDK child process. OAuth via your Claude Max subscription. |
llm-bawt-codex-bridge | llm-bawt-bridge | Talks JSON-RPC to a static-pie Rust binary. OAuth via your ChatGPT subscription. |
llm-bawt-playwright-mcp | mcp/playwright | Headless browser MCP server. Token-cost guarded with --snapshot-mode=none. |
llm-bawt-crawl4ai | unclecode/crawl4ai | Web-fetch service used by the WebFetch tool. |
06 Bridges as agent sidecars.
The three bridge containers (openclaw-bridge, claude-code-bridge, codex-bridge) all share one Docker image — llm-bawt-bridge from Dockerfile.bridge — and are started with different module entrypoints. The image runs Python 3.12-slim, non-root user bridge, and bind-mounts the host's ~/dev directory at /home/bridge/dev.
That bind mount is the key design choice. Both Claude Code and Codex are agent harnesses — they need filesystem access to read and edit code. The bridges are the workspace: when one of those bots executes Edit or Bash, it's writing to the same files your editor on the host sees. The host's ~/.ssh is also mounted read-only so the agents can clone from GitHub and SSH between hosts. The bridges themselves talk to Redis at redis:6379 over the llm-bawt_default bridge network.
Each agent's auth credentials are bind-mounted from the host config dir:
- Claude Code:
~/.config/claude-code-bridge/.claude.json+~/.config/claude-code-bridge/for settings, skills, and SOUL.md. - Codex:
~/.codex/auth.json(RW so the binary can self-refresh OAuth tokens —codex loginon the host self-heals the bridge with no docker restart). - OpenClaw: gateway URL + token in
.env.
Health checks expose /health on ports 8680 (OpenClaw), 8681 (Claude Code), 8682 (Codex). All three depend on Redis being healthy and the main app being started.
07 Unraid: the always-on services.
Unraid runs ~30 containers, but only a handful matter for BawtHub:
| Container | Image | Network | IP | Purpose |
|---|---|---|---|---|
NginxProxyManager | jlesage/nginx-proxy-manager | br0.2 | 10.0.2.2 | The reverse proxy doing actual host-based routing. Admin UI at https://npm.lan.zenoran.com (port 81 internal). |
Authelia | authelia/authelia | br0.2 | 10.0.2.99 | SSO + 2FA. NPM does an auth_request to /authz/auth-request; failed auths redirect to auth.bawthub.com. |
OAuth2-Proxy / OAuth2-Proxy-Zenoran | quay.io/oauth2-proxy/oauth2-proxy | br0.2 | — | Older auth path still wired for some zenoran.com subdomains. |
postgres-pgvector | timescale/timescaledb-ha:pg16 | br0.2 | — | The shared database for llm-bawt and BawtHub. pgvector ships in the image. |
bawthub-public-site | nginx:alpine | br0.2 | 10.0.2.36 | Static nginx serving the marketing/architecture site at bawthub.com. The pages you're reading right now. |
AdGuard-Home | adguard/adguardhome | — | — | LAN DNS + ad-blocking. Resolves *.lan.ferreri.us / *.lan.zenoran.com to LAN IPs. |
vaultwarden | vaultwarden/server | — | 172.17.0.5 | Self-hosted Bitwarden at vault.ferreri.us. Agents fetch API keys from here. |
08 Authelia gating.
NPM is configured per-host with an auth_request directive that calls Authelia's /authz/auth-request endpoint. If the request has a valid session cookie (matching one of Authelia's configured users), Authelia returns 200 + identity headers (Remote-User, Remote-Email, Remote-Groups); if not, it returns 401 and NPM redirects to https://auth.bawthub.com for login.
The auth-portal proxy host (NPM proxy_host #39, auth.bawthub.com) is special — it must not apply server_proxy[.]conf to itself or it'd loop. It's also added to $oauth2_bypass in http_top.conf. The unraid-ops skill captures this as a recurring footgun.
Identity headers from Authelia are forwarded as X-Forwarded-User / X-Forwarded-Email / X-Auth-Request-User / X-Auth-Request-Email / Remote-User / Remote-Email. BawtHub's frontend reads X-Forwarded-Email as the canonical user identity for its entityId column.
09 The public site.
This very page is served by a separate, intentionally minimal container — bawthub-public-site, plain nginx:alpine on Unraid br0.2 at 10.0.2.36. NPM routes the bare apex (bawthub.com and www.bawthub.com) here with no Authelia gate, because the marketing/architecture pages are deliberately public.
The deployment story for this site is dead simple: edit HTML, copy to /mnt/user/appdata/bawthub-public-site/, done. There is no build step, no framework, no JS framework dependency tree. Just static HTML, one shared site.css, one shared site.js, and the Inter web font.
10 The dev loop.
BawtHub's frontend is live-reloaded — that's the deploy. The frontend container bind-mounts ./frontend from disk and runs next dev --webpack. Editing a .ts/.tsx/.css file triggers webpack HMR; users see the change without a container restart.
llm-bawt's main app similarly mounts src/ in dev mode (the FastAPI worker reloads on Python file changes), though changes to that container's dependencies require a rebuild. The bridges, since they're share an image, get rebuilt with make rebuild-bridge when the Dockerfile.bridge changes — but the bind-mounted ~/dev + the live-mounted Python sources mean most changes don't need an image build.
BawtHub's app.bawthub.com is served by the same hot-reload container as echo.lan.zenoran.com. The compose profile snapshot can spin up a parallel frontend-prod service that bakes the code into the image, but it serves only snapshot.echo.lan.* — it's for A/B comparing against the live HMR build. Pushing to main + git pull on echo is the release.
11 DNS recap.
- Public records at Porkbun.
app.bawthub.com,auth.bawthub.com,bawthub.com,www.bawthub.com,echo.ferreri.us,echo.zenoran.com, and a long tail of*.ferreri.usservice subdomains all point at204.210.248.216. - LAN records via AdGuard rewrite rules:
*.lan.ferreri.usand*.lan.zenoran.comresolve to LAN IPs internally only. AdGuard is the DHCP-advertised DNS server, so every device on the LAN sees these names. - TLS certificates via Let's Encrypt DNS-01 challenge through Porkbun's API. NPM manages renewal; the
npm-17cert coversbawthub.com+ Subject Alternative Names for all the subdomains.
12 Key files + locations.
llm-bawt/docker-compose.yml~/.config/llm-bawt for bots.yaml + .env.llm-bawt/Dockerfile.bridgebridge, pre-installed claude-agent-sdk + openai-codex-sdk + the agents' CLIs.bawthub/docker-compose.ymlfrontend-prod hides behind compose profile snapshot.bawthub/frontend/hot-reloading.Dockerfile./frontend, runs next dev --webpack. This is the production image./mnt/user/appdata/NginxProxyManager/nginx/proxy_host/*.conf37.conf = app.bawthub.com, 38.conf = bawthub.com apex, 39.conf = auth.bawthub.com./mnt/user/appdata/NginxProxyManager/nginx/http_top.conf$oauth2_bypass lives. Auth-portal hostnames go here so they don't loop back through the auth check./mnt/user/appdata/bawthub-public-site/