Runtime Architecture & Deploy Pipeline¶
What actually runs where, and how code gets to production. Companion to
codebase-overview.md(code layout) and the per-directoryCLAUDE.mdguides. Sources of truth:docker-compose.yml,deploy.sh,deploy/,.github/workflows/— when any doc (including this one) disagrees with those files, the files win.
1. The big picture¶
Cloudflare (TLS)
│
Apache (VPS) ← security headers, vhosts
┌──────────┬───────────┼─────────────┬──────────────┐
│ / │ /socket/ │ /rmhbox-ws/ │ /rmhtube-ws/ │
▼ ▼ ▼ ▼ │
web:7005 socket:7001 rmhbox:7676 rmhtube:7003 │ ← Node containers
(Nitro SSR) (Socket.IO) (Socket.IO) (Socket.IO) │
│ │
│ ladder-worker · homes-worker · jobs │
│ (Node crons + pg-boss backbone, no port) │
│ │
│ supervisor:9090 (Go: discord-bot + recap + │
│ doctrine-worker + vibe-worker + bot-worker + │
│ streak-saver) │
│ status:7008 (Go) assets:7007 (Go) │
│ redis / redis-state · minio:9000/9001 (infra) │
▼ ▼
PostgreSQL R2/S3 (media)
Production is a hybrid runtime. Node is authoritative for the web SSR and
every user-facing realtime hub; Go is authoritative for the background-worker
tier, the status dashboard, and asset streaming. All container ports bind to
127.0.0.1; Apache is the only front door. rmhmusic runs inside the Node
socket-server (port 7001), not as its own service.
The former full-Go topology — a gateway fronting Go hubs
(gamehub/rmhbox/rmhtube/rmhmusic) with a Redis backplane, plus its Helm chart
and k8s manifests — was removed in the rewrite (design §5.2): it never
served a production request and duplicated the Node hubs. Go now runs only what
production uses — the supervisor (background workers), status, and
assets. The Node hubs are the realtime tier. Recover the deleted topology from
git history (tag pre-rewrite-go-realtime) if it is ever revived.
2. Images: two, from one Dockerfile¶
The root Dockerfile builds both production images; vite build runs once:
Image |
Target |
Contents |
Runs |
|---|---|---|---|
|
|
Node app: |
web, socket, rmhbox, rmhtube, ladder-worker, homes-worker, jobs |
|
|
slim image + Chromium + git + Go binaries in |
supervisor, status, assets |
3. Deploy pipeline (push → production)¶
Trigger: push to
main→.github/workflows/deploy.ymlbuilds the two images on a native ARM64 runner, pushes them to GHCR tagged with the commit SHA, then POSTs an HMAC-signed request to the VPS webhook receiver (webhook-server.cjs), which runs./deploy.sh production <sha>. (A newer commit tomaincancels the in-progress build and starts fresh —concurrency: cancel-in-progress, keyed by ref so branch dispatches don’t interfere. The deploy phase is never cancelled mid-flight:deploy.sh’s flock+queue lets a running deploy finish and runs the newest queued one next. The listener is driven by CI — not a raw push webhook — so the deploy runs after the image exists.)deploy.sh production <sha>(flock-serialized; reports to Discord + GitHub commit status):git fetch+reset --hard <sha>(self-restarts if deploy.sh itself changed)pull the two GHCR images for
<sha>and retag them to the local names compose expects (both also tagged with the git SHA for rollback)background: sync static assets + avatars to R2
prisma migrate deployin a throwaway container (skips when current)docker compose up -d --scale web=0— everything except webblue/green web hotswap (
deploy/hotswap-web.sh): start the new web container on the spare port (7005 ⇄ 7015), health-gate it, flip Apache’s active-port include with a graceful reload, stop the old containerparallel health checks; prune stale + rollback images
Rollback: previous SHA-tagged images are kept (one per env); replaced Node workers can be revived by restoring their compose command blocks (see
docs/runbooks/2026-06-22-go-runtime-cutover.md).
4. CI¶
~45 workflows live in .github/workflows/. The load-bearing gates:
Workflow |
Gates |
|---|---|
|
CD — push to |
|
Frontend gate: |
|
Typechecks the Node service tier against |
|
Test run with coverage |
|
Go fleet: |
|
CodeQL SAST for JS/TS + Go |
|
LLM review gate on PRs (Claude Opus over the diff; fails on a FAIL verdict; short-circuits green for non-owner authors) |
Plus many granular linters/scanners: prettier, eslint/markdownlint/
yamllint/actionlint/shellcheck/shfmt/hadolint/editorconfig-check,
prisma-* (format/validate/migrate-status), i18n-* (extract-check/json-valid),
security scanners (semgrep, trivy-fs, trufflehog, checkov, zizmor,
go-gosec/go-vuln/go-licenses), license-check-js, pnpm-audit-full,
compose-validate, terraform-fmt/terraform-validate, and secret-file-guard.
Dependabot (.github/dependabot.yml) covers npm (root), gomod (go-services/),
and github-actions weekly. Run at least pnpm exec tsc --noEmit, pnpm lint,
and pnpm exec vitest run locally before pushing.
5. Ports reference¶
Port |
Service |
Runtime |
Exposure |
|---|---|---|---|
7005 |
web (Nitro SSR) — blue |
Node |
Apache |
7015 |
web — green (hotswap spare) |
Node |
Apache flip target |
7001 |
socket-server (games + rmhmusic) |
Node |
Apache |
7676 |
rmhbox |
Node |
Apache |
7003 |
rmhtube |
Node |
Apache |
7004 |
recap health |
Go (in supervisor) |
internal |
7007 |
assets |
Go |
internal (Apache off-disk CDN path) |
7008 |
status |
Go |
dashboard |
7100 |
ledger |
Go |
not deployed (internal/ledger, no binary) |
9090 |
supervisor metrics |
Go |
internal (Prometheus) |
6379 |
redis (LRU cache) · redis-state (durable, dormant) |
infra |
internal |
9000/9001 |
MinIO |
infra |
internal |
(The ladder-worker, homes-worker, and jobs Node workers bind no port.
The old Go gateway/gamehub/rmhmusic:7002 hubs were removed with the Go
realtime topology.)
6. Auth across tiers¶
Issuance: Better Auth in the web tier (
lib/auth.ts) — cookies + OAuth + passkeys + Stripe customer creation./api/auth/$splat route.Node hubs: validate the session token against the shared
sessiontable (each hub has its ownauth.ts; socket-server is soft-auth, rmhbox/rmhtube/rmhmusic hard-auth).Go services:
pkg/authruns the same SQL lookup where a service needs identity. They are internal-only (workers +status+assets) and never terminate user-facing traffic — the old identity-injectinggatewaywas removed with the Go realtime topology.Server-to-server: shared-secret via
lib/internal-auth.ts(INTERNAL_API_SECRET), used e.g. by workers bridging events to the web tier’s SSE.
7. Data & media¶
PostgreSQL via Prisma 7 (
@prisma/adapter-pg) in Node and raw pgx in Go — one shared schema (prisma/schema.prisma, 234 models). Go models mirror Prisma column casing verbatim.Media: R2/S3 via
lib/storage/(local-FS fallback for dev); MinIO provides the bucket locally; the Goassetsservice streams/library /music /models /spritesrange-aware (main user path still goes through web/Apache under Compose).Async work: the
jobsservice (pg-boss over Postgres) drains durable queues — engagement progression, event reminders, the weekly digest email. The web tier is send-only;jobsowns queue maintenance/scheduling. With noDATABASE_URL,jobsexits and the web tier runs the work inline.Redis: two planes in Compose.
redis(7.4,allkeys-lru, ~320 MB) is a best-effort cache the web tier uses (feed/view/presence).redis-stateis a durable state plane (noeviction+ AOF) for rate-limit counters, dirty view/counter buffers, and presence sets — dormant by default (profileredis-state); the app routes state keys there only onceREDIS_STATE_URLis set, otherwise it falls back to the cache connection (lib/redis.server.ts). If Redis is entirely absent the web tier degrades to in-process. Node realtime hubs hold state process-locally (single-instance; no socket.io Redis adapter).
8. Observability¶
Client: Core Web Vitals →
/api/rum; uncaught errors →/api/client-error(installed inapp/routes/__root.tsx).Server: structured JSON logs everywhere (Node
server/shared/logger.ts, Gopkg/logwith matching field names). Go services expose Prometheus metrics (pkg/telemetry); supervisor merges its workers’ registries on :9090.Status page: the Go
statusservice (7008) probes every service’s/health+ the DB and keeps uptime history on disk — designed to survive platform outages.Deploy telemetry: Discord webhooks + GitHub commit statuses from
deploy.sh.