Runtime Architecture & Deploy Pipeline

What actually runs where, and how code gets to production. Companion to codebase-overview.md (code layout) and the per-directory CLAUDE.md guides. 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 (infra; minio = local-s3)    │
   ▼                                                   ▼
 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

rmhstudios-app (slim)

runner

Node app: .output/ (Nitro) + dist-server/*.cjs bundles

web, socket, rmhbox, rmhtube, ladder-worker, homes-worker, jobs

rmhstudios-app-full

runner-full

shared runtime base + Chromium + Go binaries in /app/bin/ (built by the Dockerfile’s go-builder stage — plain go build, not Bazel); no Node app layers

supervisor, status, assets

3. Deploy pipeline (push → production)

  1. Trigger: push to main → .github/workflows/deploy.yml builds 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 to main cancels 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.)

  2. 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 deploy in a throwaway container (skips when current)

    • docker compose up -d --scale web=0 — everything except web

    • blue/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 container

    • parallel health checks; prune stale + rollback images

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

Eleven workflows live in .github/workflows/ — the fleet was consolidated in the rewrite, and this table used to describe the ~45-workflow one that preceded it (typecheck-server.yml, vitest-coverage.yml, codeql.yml and the granular linter/scanner set are all gone). ls .github/workflows/ is the truth; this is what each one gates:

Workflow

Gates

deploy.yml

CD — push to main → build + push images to GHCR → trigger VPS listener (above). Its in-workflow ci job re-runs typecheck/lint/tests; the test step is advisory there because the PR already gated it.

web-ci.yml

Frontend gate on PRs: tsc --noEmit + eslint + docs freshness + pnpm test (check job — all blocking), build:frontend:sourcemap + the two size gates (build job), pnpm audit --prod (audit job)

epic-tests.yml

The epic content-build suite (pnpm test:epic) with a Chromium install; path-filtered to scripts/epic/**

go-microservices.yml

Go fleet: bazelisk test --build_tests_only //go-services/..., path-filtered to go-services/**. (The Postgres-backed e2e + helm lint jobs were removed with the Go realtime/Helm topology — design §5.2.)

senior-review.yml

LLM review gate on PRs (Claude Opus over the diff; fails on a FAIL verdict; short-circuits green for non-owner authors)

synthetic-perf.yml

Lighthouse runs against the deployed site, scored by scripts/ci/synthetic-perf-report.mjs

i18n-translate.yml

Machine-translates new en keys into the other 15 locales

build-vibe-packages.yml, compose-validate.yml, prisma-validate.yml, prisma-migrate-status.yml

Build/deploy guards — the vibe package bundle, docker compose config, prisma validate, and migration drift

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 test locally before pushing — or just pnpm check:consistency, which runs the changed-file subset of all three. Test suites and what gates them: testing.md.

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 /socket/

7676

rmhbox

Node

Apache /rmhbox-ws/

7003

rmhtube

Node

Apache /rmhtube-ws/

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 session table (each hub has its own auth.ts; socket-server is soft-auth, rmhbox/rmhtube/rmhmusic hard-auth).

  • Go services: pkg/auth runs 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-injecting gateway was 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 Go assets service streams /library /music /models /sprites range-aware (main user path still goes through web/Apache under Compose).

  • Async work: the jobs service (pg-boss over Postgres) drains durable queues — engagement progression, event reminders, the weekly digest email. The web tier is send-only; jobs owns queue maintenance/scheduling. With no DATABASE_URL, jobs exits 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-state is a durable state plane (noeviction + AOF) for rate-limit counters, dirty view/counter buffers, and presence sets — dormant by default (profile redis-state); the app routes state keys there only once REDIS_STATE_URL is 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 in app/routes/__root.tsx).

  • Server: structured JSON logs everywhere (Node server/shared/logger.ts, Go pkg/log with matching field names). Go services expose Prometheus metrics (pkg/telemetry); supervisor merges its workers’ registries on :9090.

  • Status page: the Go status service (7008) keeps uptime history on disk and is designed to survive platform outages — its dashboard is one self-contained document that fetches nothing, not even a font. Two kinds of probe: the web-facing ones (/, /api/ready, /blog/rss.xml, /sitemap.xml) go through the public origin, so they traverse the real user path and assert on response CONTENT — a 200 serving an empty shell reads as degraded, not operational; the internal ones (the three Node hubs, the supervisor) hit each container’s own /health over compose DNS. The hubs are deliberately NOT probed at <origin>/socket/health etc: those paths exist only as rewrites in the hand-installed Apache vhost, so probing them reported a permanent 404 outage for hubs that were serving traffic normally.

  • Deploy telemetry: Discord webhooks + GitHub commit statuses from deploy.sh.