fly.staging.toml
Rama main · réplica de lectura del repositorio original.
# Fly.io configuration for the STAGING API + WebSocket server (pqp-api-staging).
#
# Staging scales to zero when idle: auto_stop_machines = "stop",
# auto_start_machines = true, min_machines_running = 0. The price of the trade
# is only that the first request after an idle period takes a few seconds while
# the machine boots; nobody on staging is mid-call. (This file started as a
# copy of the production fly.toml, which was deleted on 2026-10-22 when
# production's Fly rollback was retired: production runs on the Vultr box,
# tools/api-host/. This is now the only Fly config for the API in the repo, and
# the one a self-host on Fly would start from; docs/deploy-fly.md.)
#
# Deployed by .github/workflows/deploy-staging.yml (push to the `staging`
# branch, or workflow_dispatch on any ref). Operator doc: docs/STAGING.md.
#
# THE ONE THING TO KNOW BEFORE EDITING THIS FILE
# -----------------------------------------------------------------
# This server is single-process BY DESIGN. WebSocket connection state, presence,
# voice-room membership and rate-limit buckets all live in in-process Maps
# (server/src/ws/, server/src/lib/rate-limit.ts). There is no pub/sub layer.
# A SECOND MACHINE SILENTLY SPLITS THE USERBASE — even on staging that makes
# every two-person test look broken. Machine count is not expressible in this
# file; the guard rail is `--ha=false` on every deploy (CI does this).
app = "pqp-api-staging"
# Same region as production and as the pqp-db cluster that holds pqp-staging.
# A cross-region DB would make staging slower than prod for no reason.
primary_region = "gru"
# The server installs SIGTERM/SIGINT handlers that close every socket with
# code 1001 ("going away") and drain the HTTP server and pg pool before exit
# (server/src/index.ts). Give it room to finish.
kill_signal = "SIGTERM"
kill_timeout = 30
[build]
# Same multi-stage image production builds, unchanged. The runner stage sets
# NODE_ENV=production, runs as the `node` user and CMDs
# `node server/dist/index.js`.
dockerfile = "Dockerfile"
[env]
# Non-secret configuration lives here (in git, reviewable). Everything with a
# credential in it goes through `fly secrets set -a pqp-api-staging`. Do NOT
# also set these as secrets: a secret of the same name shadows [env].
# Must agree with `internal_port` below; a silent disagreement presents as
# "deploy succeeded, health check never passes".
PORT = "3001"
# Exactly one proxy (fly-proxy) sits in front of this process and appends the
# real client address to X-Forwarded-For. `clientAddress()` counts in from the
# RIGHT of that chain, so the depth has to be right: too low and the rate
# limiter keys on an attacker-supplied value, too high and it falls back to
# the socket address, which behind the proxy is one shared bucket for
# everybody. One hop = "true". Raise this only if you put another proxy
# (e.g. Cloudflare proxied DNS) in front of Fly.
TRUST_PROXY = "true"
# pg pool ceiling per process. Staging shares the pqp-db cluster with
# production's database, so keep this modest.
PG_POOL_MAX = "10"
[http_service]
internal_port = 3001
force_https = true
# STAGING DIFFERENCE. Production says "off" here because a stopped machine
# terminates every live WebSocket; on staging that is an acceptable trade for
# not paying for an idle machine. Clients reconnect with backoff anyway
# (client/src/lib/realtime.ts). "stop", not "suspend": a suspended machine
# restores in-process presence/voice Maps from a snapshot that no longer
# matches any connected client.
auto_stop_machines = "stop"
# STAGING DIFFERENCE. The proxy wakes the machine on the first request after
# an idle stop. Note this only ever restarts the one machine that exists —
# fly-proxy never creates machines, so this is not the second-machine risk.
auto_start_machines = true
# STAGING DIFFERENCE. Zero: idle staging costs nothing.
min_machines_running = 0
processes = ["app"]
[http_service.concurrency]
# `connections`, NOT `requests` — every signed-in client holds one
# WebSocket for its whole session. Same numbers as prod; the defaults
# (soft 20 / hard 25) 503 the 26th connection on an idle app.
type = "connections"
soft_limit = 400
hard_limit = 600
# Health check. `/health` does a real `SELECT 1`, so a 200 means "process up
# AND database reachable". Same slack timings as prod: a tight interval turns
# a two-second Postgres blip into a failed check.
[[http_service.checks]]
method = "GET"
path = "/health"
protocol = "http"
interval = "30s"
timeout = "5s"
# initDb() runs the whole of server/src/schema.sql before listen(), and the
# first boot against the empty pqp-staging database is the slow one. Do not
# shrink this to where a cold start looks like a failure.
grace_period = "30s"
[[vm]]
# Same size as prod so staging behaves like prod: signalling and chat only,
# media never touches this process.
size = "shared-cpu-1x"
memory = "1gb"
[deploy]
# rolling + max_unavailable = 1 on a single machine: replace it in place.
# Do NOT switch to "bluegreen" or "canary" — both run a second machine
# alongside the first, the split-brain this app cannot survive.
strategy = "rolling"
max_unavailable = 1
# No release_command. Schema application is initDb() at boot
# (server/src/db.ts reads server/src/schema.sql), which is idempotent and
# runs before listen() — it is also what creates the whole schema in
# pqp-staging on the very first boot; a release_command would be a second
# machine talking to the same database for no benefit.