BawtHub
⌕ Search ⌘K Source ↗ Open app →
cross-cutting · deployment

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.

Public IP: 204.210.248.216 (Spectrum residential) Edge: openresty → NPM → backends Auth: Authelia at auth.bawthub.com / auth.ferreri.us / auth.zenoran.com

01 Two physical hosts.

Everything runs on two machines:

HostRoleLAN IPWhat lives here
echoWorkstation / compute10.0.0.101BawtHub frontend + backend, llm-bawt-app (FastAPI + GPU), all three agent bridges, Redis, the moshi STT/TTS pair, traefik, the playwright MCP, crawl4ai, nocodb
unraidNAS / always-on services10.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.

NetworkHostDriverSubnetUsed by
br0unraidipvlan10.0.0.0/24 (LAN)Containers that need a LAN IP indistinguishable from physical hosts (legacy)
br0.2unraidipvlan10.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
bridgeunraidbridge172.17.0.0/16Containers that don't need a routable IP (vaultwarden, etc.)
bawthub_defaultechobridge172.19.0.0/16traefik, frontend, backend, tts, stt, nocodb
llm-bawt_defaultechobridge172.18.0.0/16llm-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.

Request flow · external → backend
Internet
DNS @ Porkbunapp.bawthub.combawthub.comecho.ferreri.us204.210.248.216
Edge
openresty (TLS termination)Spectrum gatewayport 443 only
Routing
NginxProxyManager @ 10.0.2.2per-host .conf filesLet's Encrypt via Porkbun DNS-01
Auth gate
Authelia @ 10.0.2.99:90912FA + SSOauth.bawthub.com portal
Backends
echo @ 10.0.0.101:80 (traefik)bawthub-public-site @ 10.0.2.36:80Various Unraid services

04 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.

HostnameVisibilityAuth?Routes to
bawthub.com / www.bawthub.comPublicNonebawthub-public-site @ 10.0.2.36:80 (nginx:alpine serving this very site)
app.bawthub.comPublicAutheliaecho @ 10.0.0.101:80 → traefik → bawthub-frontend
auth.bawthub.comPublic(it is the auth portal)Authelia @ 10.0.2.99:9091
echo.ferreri.us / echo.zenoran.comPublicAutheliaecho @ 10.0.0.101:80 (same as app.bawthub.com; traefik fans out by Host header)
echo.lan.ferreri.us / echo.lan.zenoran.comLAN onlyNone (LAN-trusted)Same — but resolved by AdGuard, bypassing the public path entirely
dev.echo.lan.*LAN onlyNoneSame container — legacy alias kept working
snapshot.echo.lan.*LAN onlyNoneOpt-in baked snapshot frontend (compose profile snapshot) — for A/B compares
vault.ferreri.us, plex.ferreri.us, etc.PublicAuthelia (most)Direct container on Unraid (per NPM proxy host)
The LAN naming convention.

*.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:

ContainerImagePurpose
bawthub-traefik-1traefik:v3.6.14HTTP fan-out by Host header. Catches every public + LAN hostname for the frontend container.
bawthub-frontend-1bawthub-frontend:latestNext.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-1bawthub-backend:latestPython 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-1moshi-server-prebuiltGPU-pinned moshi voice pipeline — TTS on port 8089, STT on 8090.
llm-bawt-appllm-bawt-appFastAPI service on :8642, MCP server on :8001. Holds the bot configs, memory pipeline, and chat-completion router.
llm-bawt-redisredis:7-alpineEvent bus for the bridges. maxmemory 128mb, allkeys-lru.
llm-bawt-openclaw-bridgellm-bawt-bridgeWebSocket client to the remote OpenClaw gateway. Translates upstream events to AgentEvents.
llm-bawt-claude-code-bridgellm-bawt-bridgeSpawns the Claude Code SDK child process. OAuth via your Claude Max subscription.
llm-bawt-codex-bridgellm-bawt-bridgeTalks JSON-RPC to a static-pie Rust binary. OAuth via your ChatGPT subscription.
llm-bawt-playwright-mcpmcp/playwrightHeadless browser MCP server. Token-cost guarded with --snapshot-mode=none.
llm-bawt-crawl4aiunclecode/crawl4aiWeb-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:

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:

ContainerImageNetworkIPPurpose
NginxProxyManagerjlesage/nginx-proxy-managerbr0.210.0.2.2The reverse proxy doing actual host-based routing. Admin UI at https://npm.lan.zenoran.com (port 81 internal).
Autheliaauthelia/autheliabr0.210.0.2.99SSO + 2FA. NPM does an auth_request to /authz/auth-request; failed auths redirect to auth.bawthub.com.
OAuth2-Proxy / OAuth2-Proxy-Zenoranquay.io/oauth2-proxy/oauth2-proxybr0.2Older auth path still wired for some zenoran.com subdomains.
postgres-pgvectortimescale/timescaledb-ha:pg16br0.2The shared database for llm-bawt and BawtHub. pgvector ships in the image.
bawthub-public-sitenginx:alpinebr0.210.0.2.36Static nginx serving the marketing/architecture site at bawthub.com. The pages you're reading right now.
AdGuard-Homeadguard/adguardhomeLAN DNS + ad-blocking. Resolves *.lan.ferreri.us / *.lan.zenoran.com to LAN IPs.
vaultwardenvaultwarden/server172.17.0.5Self-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.

Production = HMR. There is no separate "prod" build step.

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.

12 Key files + locations.

llm-bawt/docker-compose.yml
The llm-bawt stack on echo. Five services: app, redis, openclaw-bridge, claude-code-bridge, codex-bridge, playwright-mcp, crawl4ai. Bind mounts ~/.config/llm-bawt for bots.yaml + .env.
llm-bawt/Dockerfile.bridge
The shared bridge image. Python 3.12-slim, non-root user bridge, pre-installed claude-agent-sdk + openai-codex-sdk + the agents' CLIs.
bawthub/docker-compose.yml
The BawtHub stack on echo. traefik + frontend (HMR, bind-mounted) + backend + tts + stt. frontend-prod hides behind compose profile snapshot.
bawthub/frontend/hot-reloading.Dockerfile
The live frontend image. Bind-mounts ./frontend, runs next dev --webpack. This is the production image.
/mnt/user/appdata/NginxProxyManager/nginx/proxy_host/*.conf
NPM routing rules on Unraid. One file per proxy host, numbered. 37.conf = app.bawthub.com, 38.conf = bawthub.com apex, 39.conf = auth.bawthub.com.
/mnt/user/appdata/NginxProxyManager/nginx/http_top.conf
Where $oauth2_bypass lives. Auth-portal hostnames go here so they don't loop back through the auth check.
/mnt/user/appdata/bawthub-public-site/
Static content for bawthub.com. The HTML files for this site, including this very page.
Validated against main on 2026-05-13 Source: llm-bawt + bawthub