ADR-0015: Staging tier with build-once, promote-by-button releases
Status: Accepted — promotion model and secrets handling amended by ADR-0016 (promotion is now automatic behind the staging health gate; env files come from GitHub Secrets) Date: 2026-07-30
Context
ADR-0014 gave the hosted product a CI pipeline, but every merge to main
deployed straight to production — the only pre-production verification was
the local dev stack, which differs from production in mail, billing,
ALTCHA posture, and data. Flyway migrations (forward-only, no
down-migrations) ran their first real rehearsal against the production
database. A staging tier was wanted without a second server bill and
without the drift of a long-lived stage branch.
Decision
- Staging is a second full stack on the same VPS —
deploy/docker-compose.stage.yml: own Postgres, own secrets (.env.stage),motorph_stage_*service names, served atstage.<domain>. Deliberate deltas from production: mail lands in a bundled Mailpit (loopback UI) instead of a relay, and billing points at Polar's sandbox. Everything else — ALTCHA on, Swagger off, RLS activation — mirrors production on purpose. - The edge is its own compose project
(
deploy/docker-compose.edge.yml): one Caddy owning 80/443, routing both hostnames by service name over a shared external Docker network (motorph_payroll_network, created bydeploy.sh). Either app stack can be up, down, or mid-deploy without the other losing TLS; Caddy resolves upstreams per request. - Build once, promote the artifact. Every merge to
mainbuilds both images once (sha-<12>to GHCR) and auto-deploys staging (deploy.yml). Production deploys only viapromote.yml, a manual dispatch that ships staging's current tag — read from.deploy-state.stageon the VPS — through the same health-gated, auto-rollback path. There is no long-livedstagebranch: staging tracksmain; promotion moves an image tag, not a ref. deploy/deploy.shbecame target-aware (stage|prod deploy/rollback/backup, pluspromote), with per-environment env/state files and one globalflock.rollback.ymlgained an environment choice.
Consequences
Positive
- Migrations, seeders, ALTCHA, CSP, and the signup flow rehearse against a production-shaped stack before production sees them — with mail capture making the signup E2E testable without a real inbox.
- Production only ever runs images staging already ran; "works on staging" and "what production got" cannot drift, because they are the same bytes.
- No second branch to keep in sync, no second server to pay for.
Negative
- One VPS now runs two JVMs and two Postgres instances: 4 GB RAM minimum, and a staging load test can starve production — staging is for correctness, not performance work.
- Staging shares the host's fate: a VPS outage takes both environments (acceptable — staging exists to protect production from bad code, not bad hardware).
stage.<domain>is public (Cloudflare-fronted like production), so staging inherits the seeded-account rotation duty and the firewall trust model. Its data is disposable; its attack surface is not.- Promotion is a human decision with no enforced dwell time — discipline, not tooling, decides how long a tag bakes on staging.
References
../../deploy/docker-compose.edge.yml,../../deploy/docker-compose.stage.yml,../../deploy/deploy.sh,../../deploy/Caddyfile../../.github/workflows/deploy.yml,../../.github/workflows/promote.yml,../../.github/workflows/rollback.yml../git-workflow.md— the local → stage → prod flow../deployment/vps-guide.md— operations- 0014-caddy-origin-tls-ghcr-cicd.md