# Phase 5: Deploy - Context

**Gathered:** 2026-05-08
**Status:** Ready for planning

<domain>
## Phase Boundary

Ship the Phase 4 server (`apps/server`, Node 22 + Colyseus 0.17.10 + Better-Auth + better-sqlite3) to Fly.io as **two apps** — `rebno-staging` and `rebno-prod` in region `lax` — with Litestream WAL replication to per-app Tigris buckets, a CI/CD pipeline that auto-deploys staging on every merge to `main` and promotes the verified image to prod via git tag, a third Fly app `rebno-obs` running OpenObserve as a self-hosted OTel sink (logs + metrics + traces from both game-server apps), `/health` consumed by Fly health checks, structured pino JSON logs, and a `RESTORE.md` runbook validated end-to-end on staging in <5 minutes.

In scope:
- **Multi-stage Dockerfile** at `apps/server/Dockerfile` (DEP-01) — Alpine/musl-compatible image; `argon2 0.44.0` and `better-sqlite3 12.9.0` build clean against Node 22 musl prebuilds; entrypoint runs `drizzle-kit migrate` before `node dist/index.js`; pre-migrate Litestream snapshot ritual baked into entrypoint script.
- **`fly.toml`** for `rebno-staging` and `rebno-prod` (DEP-02) — region `lax`, single machine each, `auto_stop_machines = "off"`, `min_machines_running = 1`, persistent volume mounted at `/data` for SQLite + WAL + `/data/keys/` (Phase 4 D-11 Ed25519) + `/data/seed/` (one-shot legacy import landing zone).
- **Litestream sidecar config** at `apps/server/litestream.yml` (DEP-03) — replicates `/data/rebno.db` WAL frames to per-app Tigris buckets (provisioned via `fly storage create` per app); RPO < 1 second target measured on staging soak; pre-migrate snapshot capture via `litestream snapshots`.
- **GitHub Actions pipeline** (DEP-04) — `deploy-staging.yml` on push-to-main runs `pnpm verify:phase-4` + `pnpm trace:check` + image build + `flyctl deploy -a rebno-staging --image <sha>` + scripted 30-min 2-client soak post-deploy. `deploy-prod.yml` on tag push `v*.*.*` re-deploys the same staging-verified image SHA to `rebno-prod`. `trace:check` is a required PR check AND wired into `verify-phase-N` workflows (closes Phase 4 carry-forward DEP-04 hard-gate).
- **`/health` endpoint** (DEP-05) — already implemented in `apps/server/src/health.ts`; Fly TCP+HTTP checks consume it; reports `{status, ws_ready, rooms_loaded}`.
- **pino JSON → Fly stdout + OTel → OpenObserve** (DEP-06) — pino writes JSON to stdout (Fly built-in viewer + `fly logs` retain it); an OTel collector co-located in each game-server container ships logs + Node process metrics + Colyseus room metrics + game-loop tick metrics + per-message traces via OTLP-HTTP to `rebno-obs.fly.dev`; redact list per Phase 4 D-23.
- **`rebno-obs` Fly app** — third Fly app running OpenObserve single-binary; backed by its own `fly storage create` Tigris bucket; UI gated by Fly proxy IP allowlist + admin password (env-injected via `fly secrets set`); receives OTLP from both game-server apps.
- **`RESTORE.md` runbook** (DEP-07) — documents end-to-end SQLite restore from Tigris in <5 minutes; validated on `rebno-staging`; covers cold restore, point-in-time replay (within Litestream retention window), legacy-credentials staging-table read-once-then-purge step (per Phase 3 D-04 / Phase 4 D-17), Ed25519 keypair recovery semantics, secret rotation procedures (BETTER_AUTH_SECRET + Tigris keys), and `kill -9` mid-tick recovery (closes Phase 4 carry-forward SRV-08).
- **Fly WS proxy alignment** (DEP-08) — Fly's 60s idle timeout vs Colyseus default 3s `pingInterval` is structurally non-issue; verified during 30-min scripted soak on staging.
- **`STAGING_INVITE_TOKEN`** middleware on `apps/server` — staging-only env-gated WS handshake check (rejects connections without `?invite=<token>`); zero-cost on prod (env unset → middleware no-ops).
- **Phase 4 carry-forward verification** (runs on first staging deploy):
  - **SRV-08 kill -9 mid-tick** — verbatim 6-step procedure from `04-09-SUMMARY.md` §"Manual Verification (Phase 5 Debt)", recorded in `04-HUMAN-UAT.md` Test 1.
  - **argon2id prod-hardware bench** — `apps/server/argon2-bench.mjs` re-run on Fly shared-cpu-2x; if mean < 200 ms, bump `memoryCost` 65536→131072 or `timeCost` 3→4; documented in `04-HUMAN-UAT.md` Test 3.
  - **Multi-client move+chat smoke** — 2 ws clients against deployed staging, doubles as DEP-08 soak test (`04-HUMAN-UAT.md` Test 2).

Out of scope (belongs in later phases):
- `apps/client` Vite + Phaser scaffold + browser deployment — Phase 6 CLI-01..CLI-09.
- AST-01 BMP→PNG sprite atlas pipeline — Phase 6.
- AST-02..04 (MIDI→OGG, BMP fonts→WOFF2, content-hashed manifest) — Phase 7.
- Modernized admin web UI (PAR-07) — Phase 7. SRV-12 stubs already exist in `apps/server/src/admin-stubs.ts`.
- Full chat surface (whispers, channels, ignore/block, profanity, history rolling buffer) — Phase 7 PAR-04.
- `.bnu` per-user transactional character migration — Phase 7 PAR-05. Phase 5 ships only the auth+position slice that Phase 4 already wired.
- Multi-region Fly deployment / sharding / Postgres migration — v2 (OPS-01..03).
- LiteFS read-replicas — v2 only.
- Fly autoscaling + Colyseus matchmaker driver (Redis) — v2.
- Public OAuth/passkey providers — Phase 7 PAR-06.
- Production OAuth login for OpenObserve UI — v1 keeps single admin password.
- Quarterly forced secret rotation — v1 documents rotation procedures only; cadence deferred.

</domain>

<decisions>
## Implementation Decisions

### Environment topology

- **D-01 (two-app split):** `rebno-staging` + `rebno-prod` are independent Fly apps, each with its own Fly Volume + Tigris bucket. RESTORE.md is validated end-to-end on staging without prod blast-radius. Phase 4 carry-forward (kill -9 mid-tick, argon2 bench, multi-client smoke) executes on staging during initial deploy. Two-app fixed cost <$10/mo at <50 CCU stays well within budget.
- **D-02 (region `lax`):** Both apps in Los Angeles (US-West). Same region for staging and prod so latency profile + Tigris RTT translate 1:1 from RESTORE.md validation runs to prod incidents. Audience is 1-5 people, operator is West-Coast-adjacent → minimum operator latency. Region revisitable if audience expands. Domains: `staging.rebno.decidel.com` + `rebno.decidel.com` (Namecheap-managed; A/AAAA records added per `fly certs add` output post-deploy).
- **D-03 (staging persists across redeploys):** `pnpm migrate:legacy-accounts` runs **once** on initial staging deploy (mirrors the prod ritual exactly, dogfooded once before prod ever sees it). Subsequent staging redeploys preserve the SQLite DB on the Fly Volume. Volume wipe is an explicit operator action when needed (e.g., schema-test reset), documented in RESTORE.md.
- **D-04 (staging access = IP allowlist + invite token):** Staging carries real `localList.txt`-derived plaintext credentials (until first-login rehash purges them). Two-layer gate: (a) Fly Machines TCP allowlist on operator IP(s) via `fly ips allocate --network` configuration, and (b) `apps/server` middleware that rejects WS handshakes lacking `?invite=<STAGING_INVITE_TOKEN>` when `process.env.STAGING_MODE === '1'`. Token rotates via `fly secrets set` on demand. Prod machines never set `STAGING_MODE` (middleware no-ops; public WSS endpoint).

### CI/CD pipeline shape (DEP-04)

- **D-05 (push-to-main → auto-staging; tag → prod):** `.github/workflows/deploy-staging.yml` triggers on push to `main` paths matching `apps/**`, `packages/**`, `tools/**`, `Dockerfile`, `fly.toml`, `litestream.yml`. Steps: `pnpm install --frozen-lockfile` → `pnpm verify:phase-4` → `pnpm trace:check` (hard gate) → docker build with image tag = full git sha → `flyctl deploy -a rebno-staging --image registry.fly.io/rebno-staging:${sha}` → post-deploy scripted 30-min 2-client soak. `.github/workflows/deploy-prod.yml` triggers on tag push `v*.*.*`; resolves the staging image SHA from the tag's annotation (or `git notes` lookup) and runs `flyctl deploy -a rebno-prod --image <sha>` — same bits that survived staging.
- **D-06 (trace:check hard-gate placement):** `pnpm trace:check` is a **required PR check** (`.github/workflows/trace-check.yml` on `pull_request`) AND wired into every `verify-phase-N.yml` workflow. Closes Phase 4 carry-forward DEP-04 finding (planner-template parse_error / undeclared_id sweep). PR cannot merge to `main` if `missing_stage` for any phase's reqs.
- **D-07 (promotion via git tag):** Operator creates tag `v0.5.0` (or `v0.5.0-rc.1` for pre-release) on a verified-on-staging commit; `git push --tags`; `deploy-prod.yml` fires. Audit trail = git history. Rejected: PR-label-based promotion (mixes review and release flow); GitHub-UI-button promotion (less audit discipline).
- **D-08 (rollback ritual):** `flyctl deploy -a rebno-prod --image registry.fly.io/rebno-prod:<prior-sha>` from any prior tagged release. RESTORE.md documents combined rollback (image + Litestream point-in-time restore) when a bad migration shipped data corruption alongside the bad image. Drizzle migrations are forward-only; backwards-incompatible schema changes get a 0-downtime additive rollout pattern (add nullable column → backfill → make NOT NULL in next deploy).
- **D-09 (DDL migrations via Dockerfile entrypoint):** Container entrypoint script `apps/server/docker-entrypoint.sh`:
  1. `litestream snapshots --output /data/snapshots/pre-migrate-$(date +%s).db` — Tigris-backed safety net.
  2. `node node_modules/drizzle-kit/bin.cjs migrate` — fail-fast; failure = container crashloop; Fly health check fails; no traffic.
  3. `exec node dist/index.js` — server starts.
  Migration failure does NOT trigger automatic rollback; operator decides via RESTORE.md procedure. Phase 4 only ships `0001_baseline.sql`; Phases 6/7 will add `0002_*.sql` etc.
- **D-10 (legacy-account migration is one-shot, NOT entrypoint):** `pnpm migrate:legacy-accounts` (Phase 4 D-17) runs **once per env**, manually, via `fly ssh console -a rebno-staging -C 'cd /app && pnpm migrate:legacy-accounts /data/seed/localList.txt'`. NOT part of every-deploy entrypoint (would re-process the seed file, which is supposed to leave after first import). RESTORE.md documents the per-env ritual: upload → run → verify row count → delete seed file.
- **D-11 (scripted 30-min 2-client soak in CI for DEP-08):** `.github/workflows/deploy-staging.yml` post-deploy step runs `pnpm soak:staging` (new script) for 30 minutes against `wss://rebno-staging.fly.dev`. Two scripted ws clients drive move-input + chat-send + heartbeat at realistic cadence; assertions: zero spurious WS disconnects, zero `RATE_LIMITED` events from baseline traffic, mean tick latency p95 < 25 ms. Reuses Phase 4 `apps/server/test/authority.integ.test.ts` two-client harness extended for duration. Failure = staging deploy marked red but does NOT auto-rollback (operator decides). Doubles as the DEP-08 Fly idle-timeout / Colyseus pingInterval verification.

### Logging + observability (DEP-06)

- **D-12 (dual-rail: Fly stdout AND OpenObserve via OTel):** pino JSON writes to stdout (Fly's built-in `fly logs` viewer keeps short retention; useful for fast-feedback debugging). In parallel, an OTel collector co-located in each game-server container (sidecar process via the entrypoint script, or in-process via `@opentelemetry/sdk-node` — locked at planning time; planning agent picks the lower-overhead option after measuring) ships logs + metrics + traces over OTLP-HTTP to `rebno-obs.fly.dev:4318`. Failure to ship to OpenObserve is non-fatal — game server keeps logging to stdout.
- **D-13 (signals: logs + metrics + traces, all three):**
  - **Logs:** Every Colyseus event (auth/onJoin/onLeave/onMessage/error), every persistence write, every rate-limit drop, every SIGTERM step, every fs.watch room hot-reload event, every legacy-credential-staging hit. Per Phase 4 D-23 redact list (passwords, session tokens, argon2id_hash, legacy_hash). Trace IDs propagate from request → tick handler → state-diff broadcast.
  - **Metrics:** Node process (RSS, CPU, GC counts/durations), Colyseus (`rooms_active`, `players_per_room` histogram, `messages_in_per_sec` by msg_type, `messages_out_per_sec` by msg_type), game-loop (`tick_duration_ms` p50/p95/p99, `step_duration_ms`, `accumulator_lag`), persistence (`db_write_ms` by table, `litestream_replicate_ms`), auth (`auth_attempts_total` by outcome, `force_reset_active`), rate-limiter (`rate_limit_drops_total` by msg_type).
  - **Traces:** Span per c2s message handler (validate → step → broadcast), span per HTTP `/api/auth/*` request, span per `room_join` (manifest verify → layout broadcast), span per persistence write batch.
- **D-14 (OpenObserve on `rebno-obs` Fly app):** Third Fly app, single OpenObserve binary, region `lax` (same as game servers — minimizes OTLP RTT). Fly Volume mounted at `/data` holds local OpenObserve state + WAL; `fly storage create` provisions a per-obs Tigris bucket backing OpenObserve's columnar log/metric/trace storage. Pricing target: Fly shared-cpu-1x / 1 GB RAM ~$2-3/mo + Tigris storage minimal at <50 CCU log volume. **Aggressive log cycling (1-5 user audience):** `ZO_DATA_RETENTION_DAYS=14` + `ZO_COMPACT_DATA_RETENTION_DAYS=30` + `ZO_USAGE_RETENTION_DAYS=14` + `ZO_MEMORY_CACHE_MAX_SIZE=256` + `ZO_FILE_PUSH_INTERVAL=60` minimize Tigris storage growth + RAM pressure on the 1GB tier.
- **D-15 (obs access):** OpenObserve UI behind same Fly proxy IP allowlist as `rebno-staging` (operator IPs only). Admin password injected via `fly secrets set ZO_ROOT_USER_PASSWORD=...` — `openssl rand -base64 24` generated locally. No public OAuth for v1; revisit if team grows.
- **D-16 (log levels):** `LOG_LEVEL=info` on prod; `LOG_LEVEL=debug` on staging by default (closes Phase 4 SRV-08 verify visibility carry-forward; surfaces argon2 bench timings). `TRACE=1` env behind manual op for tick-loop microsecond timings — used during Phase 6 perf work, off by default.

### Secrets bootstrap

- **D-17 (legacy `localList.txt` ingest = ssh-sftp ritual):** Operator scp's `legacy/servers/enlyzeam-current/localList.txt` to `/data/seed/localList.txt` on the target Fly machine via `fly ssh sftp`. Runs `fly ssh console -a <env> -C 'cd /app && pnpm migrate:legacy-accounts /data/seed/localList.txt'`. Verifies `legacy_credentials_staging` row count matches expected. Deletes `/data/seed/localList.txt`. Plaintext file never enters the Docker image, never enters git artifact path. RESTORE.md documents the ritual verbatim. Per env, performed once.
- **D-18 (BETTER_AUTH_SECRET + other secrets):** `openssl rand -base64 32` generates each secret locally; `fly secrets set BETTER_AUTH_SECRET=<value> -a rebno-staging` (then `-a rebno-prod` with a *different* value). Secrets injected as env vars at boot; encrypted at rest by Fly. Documented in RESTORE.md per-secret table. Different secret per env.
- **D-19 (Ed25519 keypair lifecycle):** Server checks `/data/keys/room_signing.ed25519` at boot. Absent → `crypto.generateKeyPairSync('ed25519')` → write both halves with mode `0600`. Persists across redeploys via Fly Volume. Operator extracts pubkey for Phase 6 Vite env via `fly ssh console -a rebno-prod -C 'cat /data/keys/room_signing.pub'` once (manual, recorded in RESTORE.md). Per-env different keys ARE acceptable — Phase 6 builds env-specific Vite bundles (`pnpm --filter @rebno/client build:staging` vs `:prod` consume different `VITE_ROOM_SIGNING_PUBKEY`).
- **D-20 (Tigris credentials = per-app `fly storage create`):** Each Fly app (`rebno-staging`, `rebno-prod`, `rebno-obs`) provisions its own Tigris bucket via `fly storage create -a <env>`. Auto-injected env vars (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_ENDPOINT_URL_S3`, `BUCKET_NAME`) become fly secrets in that app. Per-bucket isolation prevents staging-WAL from polluting prod-restore.
- **D-21 (rotation policy = on-demand, not scheduled):** `BETTER_AUTH_SECRET` rotation invalidates all existing sessions — acceptable; documented in RESTORE.md as a "force-logout" procedure. Tigris keys rotate via `fly storage destroy && fly storage create` cycle (rare; documented). Ed25519 keypair only rotates on compromise + Phase 6 client rebuild + restart — v1 records the procedure but does not schedule rotation. No quarterly forced rotation in v1.

### Carried forward from Phase 4 (locked, not re-decided)

Every Phase 4 D-* decision stands. Phase 5 explicitly relies on:
- `apps/server/src/health.ts` — `/health` endpoint (Phase 4 D-21) consumed verbatim by Fly health checks (DEP-05).
- `apps/server/src/sigterm.ts` — SIGTERM grace handler (Phase 4 D-16, plan 04-09) drives clean Fly machine shutdown.
- `apps/server/scripts/migrate-legacy-accounts.ts` (Phase 4 D-17) — one-shot legacy import, used per env.
- Phase 4 D-21 boot sequence (load env → load/generate Ed25519 → SQLite open → run migrations → mount Better-Auth → Colyseus on `/colyseus` → RoomRegistry.scan + fs.watch → SIGTERM hook → `/health`) — no Phase 5 re-derivation.
- Phase 4 D-23 pino redact list (`passwords, session tokens, argon2id_hash, legacy_hash`) — Phase 5 ships unchanged.
- ADR 0002 (SQLite + Litestream → Tigris) — re-affirmed; Phase 5 lands the actual replicator config.
- ADR 0003 (`enlyzeam-current` canonical snapshot) — drives `localList.txt` source for D-17.
- ADR 0004 (room hot-reload contract) — Phase 5 verifies fs.watch survives `kill -9` recoverability + Litestream WAL replication doesn't lock layout files.
- Phase 4 carry-forward Test 1 (kill -9 mid-tick) + Test 2 (multi-client smoke) + Test 3 (argon2 prod bench) all close on first Phase 5 staging deploy.

### Claude's Discretion

User explicit on the four areas chosen and the recommended option taken in each multi-choice question. Claude's discretion remains on:
- **Dockerfile base image** — `node:22-alpine` (musl, smallest) vs `node:22-bookworm-slim` (glibc, simpler native module rebuilds). STACK.md hints Alpine; planner verifies argon2 + better-sqlite3 prebuilt-binary availability for the chosen base before locking.
- **OTel collector form factor** — sidecar process via entrypoint vs in-process `@opentelemetry/sdk-node`. Planner picks lower-overhead option after measuring during plan 05-NN execution.
- **`flyctl` action vs hand-rolled `flyctl` invocations** in CI — `superfly/flyctl-actions/setup-flyctl@master` is the canonical published action; planner confirms current mainline action SHA at plan time.
- **OpenObserve version pin** — current stable release at plan time; lint-deploy-stack.mjs (parallel to Phase 4 D-25 lint pattern) drift-guards.

Override any decision in planning if research or codebase reading surfaces a conflict.

</decisions>

<canonical_refs>
## Canonical References

**Downstream agents (gsd-phase-researcher, gsd-planner, gsd-pattern-mapper, gsd-executor) MUST read these before planning or implementing.**

### Project planning
- `.planning/PROJECT.md` — Stage 5 active scope (Fly.io + persistent volume + Litestream + CI/CD + ops dashboard)
- `.planning/REQUIREMENTS.md` §"Deploy (Stage 5)" — DEP-01..DEP-08 acceptance text
- `.planning/ROADMAP.md` §"Phase 5: Deploy" — goal + 7 success criteria
- `.planning/STATE.md` — current position (Phase 04 ready_to_verify; Phase 05 next)
- `.planning/research/STACK.md` §"Fly.io Specifics" + §"Compatibility Matrix" + version pins (Litestream 0.3.x, Fly defaults, Tigris bucket provisioning)
- `.planning/research/PITFALLS.md` §B1 (server-authoritative) — soak test must not introduce client-trust paths
- `.planning/codebase/CONCERNS.md` §"Plaintext player credentials checked into the archive (CRITICAL)" — drives D-17 ssh-sftp-then-delete ritual

### Phase 4 outputs (consumed as input)
- `apps/server/src/health.ts` — DEP-05 endpoint already implemented
- `apps/server/src/sigterm.ts` — Phase 4 D-16 grace handler; Fly SIGTERM/30s SIGKILL window
- `apps/server/scripts/migrate-legacy-accounts.ts` — D-17 one-shot legacy import
- `apps/server/src/index.ts` — Phase 4 D-21 boot sequence; Phase 5 wraps with entrypoint script
- `apps/server/test/authority.integ.test.ts` — two-client harness extended for D-11 30-min soak
- `apps/server/argon2-bench.mjs` — Phase 4 carry-forward Test 3 source
- `packages/db/migrations/0001_baseline.sql` — first migration consumed by D-09 entrypoint
- `.planning/phases/04-server-rebuild-mvp/04-HUMAN-UAT.md` — Tests 1+2+3 close on first Phase 5 staging deploy
- `.planning/phases/04-server-rebuild-mvp/04-09-SUMMARY.md` §"Manual Verification (Phase 5 Debt)" — verbatim 6-step kill -9 procedure

### ADRs (locked decisions; Phase 5 lands operational config)
- `docs/adr/0001-client-engine.md` — Phaser 3.90 (Phase 6 only; doesn't affect Phase 5 server)
- `docs/adr/0002-persistence-layer.md` — SQLite + Litestream → Tigris locked; Phase 5 lands `litestream.yml` + bucket provisioning
- `docs/adr/0003-canonical-snapshot.md` — `legacy/servers/enlyzeam-current/localList.txt` is the D-17 import source
- `docs/adr/0004-room-hot-reload.md` — Phase 5 confirms fs.watch survives Linux SIGKILL + Litestream WAL replication doesn't lock layout files
- `docs/adr/0005-deploy-topology.md` — to be created in Phase 5 planning, records D-01..D-04 (two-app split, region, staging access pattern)
- `docs/adr/0006-observability-stack.md` — to be created in Phase 5 planning, records D-12..D-16 (OpenObserve self-hosted on `rebno-obs`)

### External implementation references
- Fly.io docs (`fly.io/docs/`) — `fly.toml` schema, `fly machines` semantics, `fly volumes`, `fly storage create` (Tigris provisioning), `fly secrets`, `fly ssh sftp`, deploy + rollback CLI
- Fly.io blog: `fly.io/blog/all-in-on-sqlite-litestream/` — official endorsement of the Phase 5 pattern
- Litestream docs (`litestream.io`) — `litestream.yml` schema, `replicas: - type: s3` Tigris config, snapshot CLI, RPO < 1s tuning, restore procedure
- Tigris docs (`fly.io/docs/tigris/`) — S3-compatibility, env vars, retention defaults
- OpenObserve docs (`openobserve.ai/docs/`) — single-binary deploy, S3-backed storage config (Tigris), OTLP-HTTP ingestion, password auth
- OpenTelemetry Node.js docs — `@opentelemetry/sdk-node`, OTLP-HTTP exporter, pino instrumentation, automatic instrumentation for `ws`/`http`/`express`
- Drizzle ORM migrations docs (`orm.drizzle.team/docs/migrations`) — `drizzle-kit migrate` runtime usage, transactional semantics
- GitHub Actions: `superfly/flyctl-actions/setup-flyctl@master` — flyctl in CI; auth via `FLY_API_TOKEN` org-level secret
- Better-Auth docs (`better-auth.com`) — secret rotation impact on sessions; cookie domain config for staging vs prod

### What NOT to read in Phase 5
- `legacy/servers/{enlyzeam-archive,local-current}/` — non-canonical per ADR 0003
- `apps/client/**` — does not exist yet; Phase 6 territory
- LiteFS docs — explicitly out of scope; v1 single-machine SQLite stays
- Postgres-on-Fly docs — explicitly out of scope per ADR 0002

</canonical_refs>

<code_context>
## Existing Code Insights

### Reusable Assets
- **`apps/server/src/health.ts`** (Phase 4) — `/health` endpoint with `{status, ws_ready, rooms_loaded}` shape; consumed verbatim by Fly TCP+HTTP checks (DEP-05).
- **`apps/server/src/sigterm.ts`** (Phase 4 D-16) — `runGraceShutdown()` 6-step grace handler; aligns with Fly's 30s SIGKILL window. Phase 5 lands the actual signal source (Fly machine stop) — code unchanged.
- **`apps/server/scripts/migrate-legacy-accounts.ts`** (Phase 4 D-17) — one-shot legacy import, invoked via `fly ssh console` per env (D-10/D-17).
- **`apps/server/argon2-bench.mjs`** (Phase 4) — re-runs on first staging deploy to close carry-forward Test 3.
- **`apps/server/test/authority.integ.test.ts`** + `reconnect.integ.test.ts` (Phase 4) — two-client harness; Phase 5 D-11 extends `authority.integ.test.ts`'s setup into a 30-min `pnpm soak:staging` GitHub Action job.
- **`packages/db/migrations/0001_baseline.sql`** (Phase 3 plan 03-06) — first migration consumed by D-09 entrypoint.
- **`scripts/verify-phase-4.mjs`** + `.github/workflows/verify-phase-4.yml` (Phase 4) — CI shape mirrored for `verify-phase-5.mjs` + `verify-phase-5.yml`; trace:check inclusion (D-06) layered on top of existing pattern.
- **`tools/scripts/lint-*.mjs`** family (Phase 2/3/4) — `lint-deploy-stack.mjs` (new in Phase 5) drift-guards `fly.toml`, `Dockerfile`, `litestream.yml`, OTel collector config; same `exit non-zero on drift` pattern.

### Established Patterns
- **TS-everywhere** (PROJECT.md). New Phase 5 scripts (`apps/server/docker-entrypoint.sh` is the only shell) live as `.mjs` Node scripts.
- **Headless, CI-runnable** (Phase 1 D-01) — entire Phase 5 verification surface runs on `ubuntu-latest` GitHub Actions; Linux is the determinism reference platform per `verify-phase-4.yml` precedent.
- **Lint-as-forcing-function** (Phase 2 D-13, Phase 3 D-22, Phase 4 D-25) — `lint-deploy-stack.mjs` joins `verify:phase-5` composite gate.
- **trace:check as required CI gate** — Phase 4 carry-forward DEP-04 hard-gate placement (D-06).
- **Mvp tagging** (Phase 2 D-04, Phase 3 D-18) — Phase 5 doesn't add new mvp/non-mvp axis; deploy infra is mvp-by-construction.
- **ADR-on-first-lock** (Phase 2 D-12, Phase 3 D-11/D-22, Phase 4 D-11) — Phase 5 produces ADR 0005 (deploy topology) + ADR 0006 (observability stack).

### Integration Points
- **Inputs:**
  - Phase 4 outputs (`apps/server/**`, `packages/db/migrations/0001_baseline.sql`)
  - Phase 1 + 2 + 3 outputs already inert at deploy time (consumed by Phase 4 server runtime)
  - `legacy/servers/enlyzeam-current/localList.txt` — D-17 ssh-sftp source
  - `.planning/research/STACK.md` §"Fly.io Specifics" — version pins + region defaults + Litestream replicator schema
  - `.planning/phases/04-server-rebuild-mvp/04-HUMAN-UAT.md` Tests 1/2/3 — carry-forward verification
- **Outputs:**
  - `apps/server/Dockerfile` (multi-stage, Alpine/musl-compatible, entrypoint runs Drizzle migrate)
  - `apps/server/docker-entrypoint.sh`
  - `apps/server/litestream.yml`
  - `apps/server/fly.staging.toml` + `apps/server/fly.prod.toml` (or single `fly.toml` with `[env]` overrides per app — planner picks)
  - `apps/obs/Dockerfile` + `apps/obs/fly.toml` (third Fly app, OpenObserve binary)
  - `.github/workflows/deploy-staging.yml` + `.github/workflows/deploy-prod.yml` + `.github/workflows/trace-check.yml`
  - `.github/workflows/verify-phase-5.yml`
  - `RESTORE.md` (root or `docs/runbooks/RESTORE.md` — planner picks)
  - `docs/adr/0005-deploy-topology.md`
  - `docs/adr/0006-observability-stack.md`
  - `tools/scripts/lint-deploy-stack.mjs`
  - `apps/server/src/staging-invite.ts` middleware
  - `apps/server/src/otel-init.ts` (OTel SDK bootstrap)
  - `scripts/soak-staging.mjs` (D-11 30-min 2-client harness)
  - `scripts/verify-phase-5.mjs` composite gate
- **Downstream consumers:**
  - Phase 6 client (CLI-02) — connects to `wss://rebno-prod.fly.dev/colyseus` (or staging URL during dev); imports `VITE_ROOM_SIGNING_PUBKEY` extracted via `fly ssh` from prod machine (D-19)
  - Phase 6 client (CLI-08 hard milestone) — runs against the Phase 5 deployed prod server
  - Phase 7 PAR-07 (admin UI) — deploys as a separate Fly app or under `/admin` path on `rebno-prod`; Phase 5 leaves the topology open
  - Phase 7 PAR-05 (`.bnu` per-user character migration) — extends `apps/server/scripts/migrate-legacy-accounts.ts` and reuses the D-17 ssh-sftp ritual

</code_context>

<specifics>
## Specific Ideas

- **Two-app split (D-01) is the explicit user lock.** RESTORE.md MUST be validated end-to-end on `rebno-staging`. Phase 4 carry-forward Tests 1 + 3 close on staging, NOT on prod's first traffic. ADR 0005 records this so future phases can't regress to single-app prod-first.
- **`STAGING_INVITE_TOKEN` middleware** (D-04) is staging-only and env-gated — `STAGING_MODE=1` toggles middleware on. Prod machine env never sets `STAGING_MODE`; the middleware simply returns `next()` (zero-cost no-op). This avoids two divergent code paths or build flavors.
- **Self-hosted observability in v1 is the explicit user lock.** OpenObserve on third Fly app `rebno-obs`; not deferred to v2 / Phase 7. Logs + metrics + traces all three signals. ADR 0006 records the choice + alternatives (Grafana stack, SigNoz) considered + rejected with reason.
- **OpenObserve = single binary, Tigris-backed, ~$2-3/mo** (D-14). User explicitly compared against Grafana Alloy+Loki+Mimir+Tempo and chose minimal-ops over ecosystem insurance. Planner must NOT silently switch to Grafana stack — ADR 0006 prevents drift.
- **Plaintext `localList.txt` enters Fly via `fly ssh sftp` only** (D-17). NEVER bake into image (would persist in registry layers). NEVER push via Tigris-staged blob (signed URL coordination overhead). Manual ritual = correct for a per-env one-shot.
- **`drizzle-kit migrate` runs in Dockerfile entrypoint, NOT app code** (D-09). Failure = container crashloop = Fly health check fails = no traffic. Pre-migrate Litestream snapshot via `litestream snapshots --output /data/snapshots/...` is the safety net.
- **Promote-to-prod via git tag, image-SHA reuse** (D-05/D-07). Prod gets the *exact same bits* that survived staging. Tag annotation OR `git notes` records the staging image SHA — planner picks the storage mechanism.
- **Phase 4 carry-forward closes on first staging deploy** (D-09/D-10/D-11). SRV-08 kill -9, argon2 bench, multi-client smoke all run as named acceptance steps in the initial staging deploy procedure documented in RESTORE.md.

</specifics>

<deferred>
## Deferred Ideas

- **Multi-region Fly deployment + Colyseus matchmaker (Redis driver) + room sharding** — v2 OPS-01..02. Phase 5 ships single-region single-machine.
- **LiteFS read-replicas** — v2 only; <50 CCU on a single machine never justifies it.
- **Postgres migration** — v2 OPS-03; ADR 0002 holds.
- **Fly autoscaling (auto-stop / auto-start machines)** — v2; v1 explicitly `auto_stop_machines = "off"`, `min_machines_running = 1` (PROJECT.md / STACK.md).
- **OAuth/passkey providers** — Phase 7 PAR-06.
- **OpenObserve OAuth/OIDC login (GitHub-bound)** — v2 / when team grows.
- **Quarterly forced secret rotation cadence** — v2; v1 documents procedures only, no schedule.
- **Real ops dashboard (Grafana, structured metrics dashboards)** — v2 OPS-04. Phase 5 ships OpenObserve with default dashboards; bespoke dashboards (per-room latency heatmaps, etc.) defer.
- **Tempo distributed tracing across multi-service architecture** — Phase 5 ships in-process traces only (single-service); meaningful only if v2 splits services.
- **Per-env Vite room-signing pubkey distribution automation** — D-19 says "manual `fly ssh cat`"; v2 could automate via secrets exchange. v1 manual is fine.
- **Phaser/Pixi client deployment + static asset hosting** — Phase 6.
- **Asset CDN on Tigris** — Phase 7 once asset pipeline (AST-01..04) ships actual content-hashed manifests.
- **Trace:check `apps/**` scanner expansion** — Phase 4 carry-forward noted scanner limitation; deferred to Phase 6 or Phase 7 when the pattern is clearer with client code in tree.

### Reviewed Todos (not folded)

None — `gsd-sdk query todo.match-phase 5` returned 0 matches.

</deferred>

---

*Phase: 05-deploy*
*Context gathered: 2026-05-08*
