---
phase: 05-deploy
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - apps/server/fly.staging.toml
  - apps/server/fly.prod.toml
autonomous: true
requirements: [DEP-02, DEP-05]
tags: [fly, fly-toml, region-lax, persistent-volume, http-checks, otel-flycast]
must_haves:
  truths:
    - "fly.staging.toml deploys rebno-staging in region lax with auto_stop_machines=off, min_machines_running=1, persistent volume at /data, /health http_check every 10s, STAGING_MODE=1, LOG_LEVEL=debug, OTEL endpoint at http://rebno-obs.flycast:5080/api/default, ALLOWED_ORIGINS=https://staging.rebno.decidel.com"
    - "fly.prod.toml deploys rebno-prod in region lax with same volume + health-check shape, NO STAGING_MODE, LOG_LEVEL=info, OTEL deployment.environment=production, ALLOWED_ORIGINS=https://rebno.decidel.com"
    - "Both files declare `[build]` empty (image is built + pushed by GitHub Actions per Plan 05/06; flyctl deploy uses --image)"
    - "Both files mount Fly Volume `rebno_data` at `/data` for SQLite + WAL + /data/keys/ + /data/seed/ + /data/snapshots/"
  artifacts:
    - path: "apps/server/fly.staging.toml"
      provides: "Fly app config for rebno-staging"
      contains: "app = \"rebno-staging\"|primary_region = \"lax\"|STAGING_MODE = \"1\"|LOG_LEVEL = \"debug\"|auto_stop_machines = \"off\"|min_machines_running = 1|source = \"rebno_data\"|destination = \"/data\"|path = \"/health\"|rebno-obs.flycast"
    - path: "apps/server/fly.prod.toml"
      provides: "Fly app config for rebno-prod"
      contains: "app = \"rebno-prod\"|primary_region = \"lax\"|LOG_LEVEL = \"info\"|auto_stop_machines = \"off\"|min_machines_running = 1|source = \"rebno_data\"|destination = \"/data\"|path = \"/health\""
  key_links:
    - from: "fly.{staging,prod}.toml [[services.http_checks]]"
      to: "apps/server/src/health.ts (Phase 4 /health endpoint)"
      via: "GET /health every 10s"
      pattern: "path = \"/health\""
    - from: "fly.{staging,prod}.toml OTEL_EXPORTER_OTLP_ENDPOINT"
      to: "apps/obs (Plan 09)"
      via: "flycast 6PN"
      pattern: "rebno-obs.flycast:5080"
    - from: "fly.staging.toml STAGING_MODE = \"1\""
      to: "apps/server/src/staging-invite.ts (Plan 10)"
      via: "env-gated middleware"
      pattern: "STAGING_MODE = \"1\""
---

<objective>
Land per-env Fly.io configuration files. Closes DEP-02 (single Fly machine + persistent volume) and contributes to DEP-05 (`/health` consumed by Fly health checks).

Purpose: Two TOML files (staging + prod) carrying Fly machine config — region, VM size, env vars (incl. STAGING_MODE gate), Fly Volume mount, and HTTP health check pointing at the Phase 4 /health endpoint. CONTEXT D-01..D-04 + D-16 + D-18..D-20 lock the shape; differences between files are minimal but security-critical.
Output: `apps/server/fly.staging.toml`, `apps/server/fly.prod.toml`. `flyctl config validate -c <file>` succeeds for each (executors run this in CI in Plan 05/06; locally the lint script (Plan 04) re-asserts the invariants).
</objective>

<execution_context>
@$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
@$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-deploy/05-CONTEXT.md
@.planning/phases/05-deploy/05-RESEARCH.md
@.planning/phases/05-deploy/05-PATTERNS.md
@apps/server/src/health.ts
@apps/server/src/env.ts
@apps/server/src/index.ts

<interfaces>
<!-- Phase 4 env loader at apps/server/src/env.ts:17-36 (zod schema). Phase 5 introduces these env keys, all set in fly.{staging,prod}.toml: -->
<!-- DATABASE_URL=/data/rebno.db, ROOM_SIGNING_PRIVATE_KEY_PATH=/data/keys/room_signing.ed25519, ROOMS_DIR=/app/rooms, ALLOWED_ORIGINS, PORT=2567, NODE_ENV=production, LOG_LEVEL, OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_RESOURCE_ATTRIBUTES, STAGING_MODE (staging only) -->
<!-- Secret env (set via `fly secrets set`, NOT in fly.toml): BETTER_AUTH_SECRET, STAGING_INVITE_TOKEN (staging only), AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_ENDPOINT_URL_S3, BUCKET_NAME (auto-injected by `fly storage create`) -->

apps/server/src/health.ts: GET /health endpoint returning {status, ws_ready, rooms_loaded}. Phase 4 D-21. Fly [[services.http_checks]] at path = "/health" with method = "get".
apps/server/src/index.ts:283-287 — Colyseus pingInterval = 3000ms (vs Fly idle 60s); DEP-08 verified by Plan 11 soak.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="false">
  <name>Task 1: fly.staging.toml — staging-specific config (STAGING_MODE=1, LOG_LEVEL=debug, deployment.environment=staging)</name>
  <files>apps/server/fly.staging.toml</files>
  <read_first>
    - .planning/phases/05-deploy/05-RESEARCH.md §"Pattern 4: fly.toml" (lines 326-384) — verbatim source
    - .planning/phases/05-deploy/05-RESEARCH.md §"Pitfall 5" (lines 650-654) — OTLP path /api/default required
    - .planning/phases/05-deploy/05-RESEARCH.md §"Pitfall 6" (lines 655-659) — auto_stop_machines = "off" hard lock
    - .planning/phases/05-deploy/05-RESEARCH.md §"Open Question 3" (lines 858-862) — two-file decision recommended
    - .planning/phases/05-deploy/05-CONTEXT.md D-01..D-04, D-12..D-16, D-18..D-20
    - .planning/phases/05-deploy/05-PATTERNS.md §"apps/server/fly.staging.toml + apps/server/fly.prod.toml" (lines 535-544)
    - apps/server/src/env.ts (zod env schema — every key in fly.toml [env] must be a known schema key)
    - apps/server/src/health.ts (verify GET /health shape — Fly check uses GET on path /health)
  </read_first>
  <behavior>
    - `app = "rebno-staging"`, `primary_region = "lax"`
    - `[build]` block exists but empty (image built by CI per Plan 05; flyctl deploy uses `--image`)
    - `[env]` includes: NODE_ENV="production", STAGING_MODE="1", LOG_LEVEL="debug", PORT="2567", DATABASE_URL="/data/rebno.db", ROOM_SIGNING_PRIVATE_KEY_PATH="/data/keys/room_signing.ed25519", ROOMS_DIR="/app/rooms", ALLOWED_ORIGINS="https://staging.rebno.decidel.com", OTEL_EXPORTER_OTLP_ENDPOINT="http://rebno-obs.flycast:5080/api/default" (NOTE the trailing `/api/default` per Pitfall 5), OTEL_RESOURCE_ATTRIBUTES="service.name=rebno-server,deployment.environment=staging"
    - `[[mounts]]` source = "rebno_data", destination = "/data", initial_size = "10gb"
    - `[[services]]` internal_port = 2567, protocol = "tcp", auto_stop_machines = "off", auto_start_machines = false, min_machines_running = 1
    - Two `[[services.ports]]` blocks: port 80 handlers=["http"] force_https=true; port 443 handlers=["tls","http"]
    - `[[services.http_checks]]` interval = "10s", grace_period = "30s", method = "get", path = "/health", protocol = "http", timeout = "2s"
    - `[[vm]]` size = "shared-cpu-2x", memory = "2gb" (Phase 4 D-07 argon2 memoryCost requires CPU; A9 risk noted)
    - File header comment line 1: `# [doc->REQ-DEP-02] [doc->REQ-DEP-05]`
  </behavior>
  <action>
    Create `apps/server/fly.staging.toml` containing exactly the keys in <behavior>. Concrete file body:
    ```toml
    # [doc->REQ-DEP-02] [doc->REQ-DEP-05]
    # Source: 05-RESEARCH.md §Pattern 4 + 05-CONTEXT.md D-01..D-04, D-12..D-16, D-18..D-20

    app = "rebno-staging"
    primary_region = "lax"

    [build]
      # Image is built + pushed by GitHub Actions (deploy-staging.yml, Plan 05).
      # flyctl deploy uses --image, not [build].

    [env]
      NODE_ENV = "production"
      STAGING_MODE = "1"
      LOG_LEVEL = "debug"
      PORT = "2567"
      DATABASE_URL = "/data/rebno.db"
      ROOM_SIGNING_PRIVATE_KEY_PATH = "/data/keys/room_signing.ed25519"
      ROOMS_DIR = "/app/rooms"
      ALLOWED_ORIGINS = "https://staging.rebno.decidel.com"
      OTEL_EXPORTER_OTLP_ENDPOINT = "http://rebno-obs.flycast:5080/api/default"
      OTEL_RESOURCE_ATTRIBUTES = "service.name=rebno-server,deployment.environment=staging"

    [[mounts]]
      source = "rebno_data"
      destination = "/data"
      initial_size = "10gb"

    [[services]]
      internal_port = 2567
      protocol = "tcp"
      auto_stop_machines = "off"
      auto_start_machines = false
      min_machines_running = 1

      [[services.ports]]
        port = 80
        handlers = ["http"]
        force_https = true

      [[services.ports]]
        port = 443
        handlers = ["tls", "http"]

      [[services.http_checks]]
        interval = "10s"
        grace_period = "30s"
        method = "get"
        path = "/health"
        protocol = "http"
        timeout = "2s"
        tls_skip_verify = false

    [[vm]]
      size = "shared-cpu-2x"
      memory = "2gb"
    ```
  </action>
  <verify>
    <automated>node -e "const fs=require('fs');const c=fs.readFileSync('apps/server/fly.staging.toml','utf-8');const required=['# [doc->REQ-DEP-02] [doc->REQ-DEP-05]','app = \"rebno-staging\"','primary_region = \"lax\"','STAGING_MODE = \"1\"','LOG_LEVEL = \"debug\"','DATABASE_URL = \"/data/rebno.db\"','ROOM_SIGNING_PRIVATE_KEY_PATH = \"/data/keys/room_signing.ed25519\"','rebno-obs.flycast:5080/api/default','deployment.environment=staging','source = \"rebno_data\"','destination = \"/data\"','auto_stop_machines = \"off\"','min_machines_running = 1','path = \"/health\"','interval = \"10s\"','size = \"shared-cpu-2x\"','memory = \"2gb\"','force_https = true'];for(const r of required){if(!c.includes(r)){console.error('MISSING in fly.staging.toml:',r);process.exit(1);}}if(c.includes('auto_stop_machines = \"stop\"')||c.includes('auto_stop_machines = \"suspend\"')){console.error('forbidden auto_stop value');process.exit(1);}console.log('OK: fly.staging.toml invariants met');"</automated>
  </verify>
  <acceptance_criteria>
    - apps/server/fly.staging.toml line 1 contains `# [doc->REQ-DEP-02] [doc->REQ-DEP-05]`
    - Contains literal `app = "rebno-staging"` and `primary_region = "lax"`
    - Contains literal `STAGING_MODE = "1"` (D-04 staging-invite middleware enable)
    - Contains literal `LOG_LEVEL = "debug"` (D-16)
    - Contains literal `DATABASE_URL = "/data/rebno.db"` (Phase 4 SQLite path)
    - Contains literal `ROOM_SIGNING_PRIVATE_KEY_PATH = "/data/keys/room_signing.ed25519"` (Phase 4 D-19 keypair path)
    - Contains literal `rebno-obs.flycast:5080/api/default` (Pitfall 5 OTLP path)
    - Contains literal `deployment.environment=staging`
    - Contains literal `source = "rebno_data"` AND `destination = "/data"` (Fly Volume mount)
    - Contains literal `auto_stop_machines = "off"` AND `min_machines_running = 1` (Pitfall 6 hard lock)
    - Contains literal `path = "/health"` AND `interval = "10s"` (Fly health check)
    - Contains literal `size = "shared-cpu-2x"` AND `memory = "2gb"` (A9 risk-acknowledged starting size)
    - Contains literal `force_https = true`
    - Does NOT contain `auto_stop_machines = "stop"` or `auto_stop_machines = "suspend"` (Pitfall 6 negative assertion)
  </acceptance_criteria>
  <done>
    File matches all acceptance criteria; verify command exits 0.
  </done>
</task>

<task type="auto" tdd="false">
  <name>Task 2: fly.prod.toml — production config (NO STAGING_MODE, LOG_LEVEL=info, deployment.environment=production)</name>
  <files>apps/server/fly.prod.toml</files>
  <read_first>
    - apps/server/fly.staging.toml (just created in Task 1 — fly.prod.toml is a near-copy with explicit diffs)
    - .planning/phases/05-deploy/05-PATTERNS.md §"apps/server/fly.staging.toml + apps/server/fly.prod.toml" (lines 537-544 — exact diff list)
    - .planning/phases/05-deploy/05-CONTEXT.md D-04 (STAGING_MODE absence on prod is the auth gate distinction)
    - .planning/phases/05-deploy/05-CONTEXT.md D-16 (LOG_LEVEL=info on prod)
    - .planning/phases/05-deploy/05-RESEARCH.md "Anti-Patterns" entry on STAGING_MODE leak (lint-deploy-stack rule)
  </read_first>
  <behavior>
    - Identical to fly.staging.toml EXCEPT:
      - `app = "rebno-prod"` (not staging)
      - NO `STAGING_MODE` line ANYWHERE (lint-deploy-stack will fail if found)
      - `LOG_LEVEL = "info"` (not debug)
      - `OTEL_RESOURCE_ATTRIBUTES = "service.name=rebno-server,deployment.environment=production"`
      - `ALLOWED_ORIGINS = "https://rebno.decidel.com"` (not staging.rebno.decidel.com)
    - Same volume name `rebno_data` (different Fly app = different physical volume; name reuse is fine and matches operator mental model)
    - Same VM size, same health check, same auto_stop=off, same min_machines=1
  </behavior>
  <action>
    Create `apps/server/fly.prod.toml` with exactly:
    ```toml
    # [doc->REQ-DEP-02] [doc->REQ-DEP-05]
    # Source: 05-RESEARCH.md §Pattern 4 + 05-CONTEXT.md D-01..D-04, D-12..D-16, D-18..D-20

    app = "rebno-prod"
    primary_region = "lax"

    [build]
      # Image is promoted via deploy-prod.yml (Plan 06) — flyctl deploy --image <staging-sha>.
      # No [build] needed.

    [env]
      NODE_ENV = "production"
      LOG_LEVEL = "info"
      PORT = "2567"
      DATABASE_URL = "/data/rebno.db"
      ROOM_SIGNING_PRIVATE_KEY_PATH = "/data/keys/room_signing.ed25519"
      ROOMS_DIR = "/app/rooms"
      ALLOWED_ORIGINS = "https://rebno.decidel.com"
      OTEL_EXPORTER_OTLP_ENDPOINT = "http://rebno-obs.flycast:5080/api/default"
      OTEL_RESOURCE_ATTRIBUTES = "service.name=rebno-server,deployment.environment=production"

    [[mounts]]
      source = "rebno_data"
      destination = "/data"
      initial_size = "10gb"

    [[services]]
      internal_port = 2567
      protocol = "tcp"
      auto_stop_machines = "off"
      auto_start_machines = false
      min_machines_running = 1

      [[services.ports]]
        port = 80
        handlers = ["http"]
        force_https = true

      [[services.ports]]
        port = 443
        handlers = ["tls", "http"]

      [[services.http_checks]]
        interval = "10s"
        grace_period = "30s"
        method = "get"
        path = "/health"
        protocol = "http"
        timeout = "2s"
        tls_skip_verify = false

    [[vm]]
      size = "shared-cpu-2x"
      memory = "2gb"
    ```
  </action>
  <verify>
    <automated>node -e "const fs=require('fs');const c=fs.readFileSync('apps/server/fly.prod.toml','utf-8');const required=['# [doc->REQ-DEP-02] [doc->REQ-DEP-05]','app = \"rebno-prod\"','primary_region = \"lax\"','LOG_LEVEL = \"info\"','DATABASE_URL = \"/data/rebno.db\"','ROOM_SIGNING_PRIVATE_KEY_PATH = \"/data/keys/room_signing.ed25519\"','rebno-obs.flycast:5080/api/default','deployment.environment=production','source = \"rebno_data\"','destination = \"/data\"','auto_stop_machines = \"off\"','min_machines_running = 1','path = \"/health\"','ALLOWED_ORIGINS = \"https://rebno.decidel.com\"','force_https = true'];for(const r of required){if(!c.includes(r)){console.error('MISSING in fly.prod.toml:',r);process.exit(1);}}const forbidden=['STAGING_MODE','LOG_LEVEL = \"debug\"','staging.rebno.decidel.com','deployment.environment=staging','rebno-staging','auto_stop_machines = \"stop\"'];for(const f of forbidden){if(c.includes(f)){console.error('FORBIDDEN in fly.prod.toml:',f);process.exit(1);}}console.log('OK: fly.prod.toml invariants met');"</automated>
  </verify>
  <acceptance_criteria>
    - apps/server/fly.prod.toml line 1 contains `# [doc->REQ-DEP-02] [doc->REQ-DEP-05]`
    - Contains literal `app = "rebno-prod"` (NOT rebno-staging)
    - Contains literal `LOG_LEVEL = "info"` (NOT debug)
    - Contains literal `ALLOWED_ORIGINS = "https://rebno.decidel.com"` (no staging subdomain)
    - Contains literal `deployment.environment=production`
    - Contains literal `auto_stop_machines = "off"` AND `min_machines_running = 1`
    - Contains literal `path = "/health"`
    - Does NOT contain the substring `STAGING_MODE` ANYWHERE (CONTEXT D-04 lock — prod must never set STAGING_MODE; lint-deploy-stack.mjs in Plan 04 will re-assert)
    - Does NOT contain `LOG_LEVEL = "debug"`, `staging.rebno.decidel.com`, `deployment.environment=staging`, or `rebno-staging`
    - Does NOT contain `auto_stop_machines = "stop"` or `auto_stop_machines = "suspend"`
  </acceptance_criteria>
  <done>
    File matches all positive acceptance criteria AND all negative assertions; verify command exits 0.
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Public internet → Fly proxy | TLS 1.2+ via Fly proxy (force_https = true) |
| Game-server container → rebno-obs | flycast (private 6PN) — never public OTLP |
| Operator → Fly secrets | `fly secrets set` (encrypted at rest); per-app + per-env different secrets |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-DEP-PROD-STAGING-LEAK | E (Elevation) | fly.prod.toml STAGING_MODE accidentally set | mitigate | Lint-deploy-stack.mjs (Plan 04) hard-fails if `STAGING_MODE` substring appears in fly.prod.toml. Verified by acceptance criteria negative assertion |
| T-DEP-AUTOSTOP | D (DoS — WS reconnect grace breaks) | auto_stop_machines | mitigate | D-02 + Pitfall 6: explicit `auto_stop_machines = "off"`, `min_machines_running = 1`. Negative assertion in acceptance criteria |
| T-DEP-OTLP-PUBLIC | I (OTLP eavesdropping) | OTEL_EXPORTER_OTLP_ENDPOINT | mitigate | Endpoint is `http://rebno-obs.flycast:5080/...` — flycast is 6PN private, never public. Lint will fail if endpoint host is `*.fly.dev` |
| T-DEP-OTLP-404 | D (Lost telemetry) | OTLP path | mitigate | Pitfall 5: path includes trailing `/api/default` so SDK appends `/v1/{traces,metrics,logs}` correctly |
| T-DEP-VOL-MISMATCH | D (DB inaccessible after deploy) | mounts | mitigate | Both files mount source = "rebno_data", destination = "/data". RESTORE.md (Plan 13) documents `fly volumes create -a <env> --name rebno_data` per-env |
| T-DEP-PROD-DEBUG | I (Verbose logs leak business data) | LOG_LEVEL on prod | mitigate | LOG_LEVEL = "info" hard-coded in fly.prod.toml; pino redact list (Phase 4 D-23) covers passwords/tokens regardless |
</threat_model>

<verification>
- Both files exist with all acceptance criteria
- Verify commands exit 0
- (Cross-plan) Plan 04 lint-deploy-stack.mjs re-asserts STAGING_MODE absence on prod, auto_stop = off on both, OTEL endpoint shape, volume name
- (Cross-plan) Plan 05 CI runs `flyctl config validate -c apps/server/fly.staging.toml`
- (Cross-plan) Plan 06 CI runs `flyctl config validate -c apps/server/fly.prod.toml`
</verification>

<success_criteria>
- DEP-02 [doc] tag present on both files
- DEP-05 [doc] tag present on both files (http_check on /health is the consumer)
- All positive + negative acceptance criteria green
- The single STAGING_MODE distinction between staging and prod is the only auth-gate code path
</success_criteria>

<output>
After completion, create `.planning/phases/05-deploy/05-02-SUMMARY.md` capturing:
- Per-env file diffs (mainly STAGING_MODE, LOG_LEVEL, ALLOWED_ORIGINS, OTEL deployment.environment attribute)
- VM size choice (shared-cpu-2x@2gb) — flag A9 risk for first staging argon2 bench
- Notes for Plan 04: lint-deploy-stack.mjs must check positive + negative assertions for both files (esp. `STAGING_MODE` absent on prod)
- Notes for Plan 05/06: CI validates each TOML via `flyctl config validate -c <path>`
</output>

## Validation
Updates 05-VALIDATION.md row `5-02-01` (REQ-DEP-02 / fly.{staging,prod}.toml parse via `flyctl config validate`) → automated command for unit-level structure check is `node -e "..."` from each task's verify; CI-level `flyctl config validate` runs in Plan 05/06 deploy workflows. Plan 04 lint adds drift guards on env-var keys + auto_stop value. Status flips to ✅ green when both files exist + Plan 04 lint passes locally + Plan 05/06 CI dry-runs validate.
