Skip to main content

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 VPSdeploy/docker-compose.stage.yml: own Postgres, own secrets (.env.stage), motorph_stage_* service names, served at stage.<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 by deploy.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 main builds both images once (sha-<12> to GHCR) and auto-deploys staging (deploy.yml). Production deploys only via promote.yml, a manual dispatch that ships staging's current tag — read from .deploy-state.stage on the VPS — through the same health-gated, auto-rollback path. There is no long-lived stage branch: staging tracks main; promotion moves an image tag, not a ref.
  • deploy/deploy.sh became target-aware (stage|prod deploy/rollback/backup, plus promote), with per-environment env/state files and one global flock. rollback.yml gained 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