Why separate environments
The purpose of multiple environments is to catch problems progressively, the further a bug gets, the more it costs. A clean setup gives you:
- Dev: fast iteration, breakable, per-developer or per-branch.
- Staging: a production-like rehearsal space where you validate before release.
- Production: the real thing, changes only via a controlled promotion.
The goal isn't bureaucracy; it's confidence. You want to be *boringly certain* a change works before it reaches users.
The cardinal rule: config in the environment, not the code
The twelve-factor principle that makes everything else possible: the same build artifact runs in every environment, and only configuration changes. You should never have if (env === 'production') branches that alter behavior, and you should never rebuild differently per environment. Same image, different env vars.
# Same code, different config per environment
# dev
DATABASE_URL=postgres://.../app_dev
LOG_LEVEL=debug
STRIPE_SECRET_KEY=sk_test_...
# production
DATABASE_URL=postgres://.../app_prod
LOG_LEVEL=info
STRIPE_SECRET_KEY=sk_live_...If the artifact differs between staging and prod, then staging didn't actually test what you're shipping.
Environment parity matters
Staging is only useful to the degree it resembles production. The closer the parity, the more bugs it catches. Aim to match:
- Same database engine and version (don't test on SQLite and ship on Postgres).
- Same runtime versions and dependencies (the identical image).
- Similar topology (if prod has a cache and a queue, staging should too).
Where parity is expensive (you rarely need prod-scale data volumes in staging), document the difference so you know what staging *won't* catch.
The branch-per-environment pattern
A clean, common workflow maps Git branches to environments:
| Branch | Environment | Trigger |
|---|---|---|
| feature/* | ephemeral / dev | push (preview) |
develop or main | staging | merge |
production (or a tag) | production | merge/tag, gated |
A change flows: feature branch → merged to staging branch (auto-deploys to staging) → validated → merged/promoted to production branch (auto-deploys to prod). Promotion is just a merge, which means it's reviewable and revertable.
Each environment needs its own data
Never point staging at the production database. Beyond the obvious risk of corrupting real data, it's a privacy and compliance problem. Each environment gets its own database, with staging seeded from a sanitized/anonymized snapshot of prod when you need realistic data.
Setting this up on PandaStack
The cleanest pattern on PandaStack is one project per environment, each connected to its own branch, each with its own managed database:
- 1Staging project connected to your
staging/mainbranch, with its own managed Postgres. PandaStack injects that environment'sDATABASE_URLautomatically, so the same code connects to the staging DB without any code change. - 2Production project connected to your
productionbranch (or tag), with its own managed Postgres and its own injectedDATABASE_URL. - 3Set environment-specific values (log level, API keys,
STRIPE_SECRET_KEYtest vs live) as env vars on each project.
Because PandaStack auto-wires the database per environment, you avoid the classic mistake of staging accidentally talking to the prod database, each project has its own injected connection string.
# Staging project env
LOG_LEVEL=debug
STRIPE_SECRET_KEY=sk_test_...
# DATABASE_URL injected -> staging DB
# Production project env
LOG_LEVEL=info
STRIPE_SECRET_KEY=sk_live_...
# DATABASE_URL injected -> production DBPandaStack's free tier (5 web services, 1 database) is generous enough to run a real staging environment cheaply for smaller apps; as you grow, paid plans give you more databases and connections per environment.
The promotion workflow in practice
- 1Developer opens a PR from a feature branch. CI runs tests.
- 2On merge to the staging branch, PandaStack rebuilds and deploys to the staging project automatically (git-push deploys).
- 3QA / smoke tests run against staging. Check the live logs and metrics.
- 4When staging is green, merge the staging branch into the production branch.
- 5PandaStack builds and deploys to production. If anything's wrong, use deploy history to roll back instantly.
The key safety property: production only ever receives code that already ran successfully in staging from the same artifact.
Database migrations across environments
Migrations are where environment promotion most often goes wrong. Rules:
- Run migrations as part of each environment's deploy, in order.
- Make migrations backward-compatible so a rollback of code doesn't break against a migrated DB (expand/contract pattern: add columns before removing the old path).
- Test migrations on staging (with a prod-like snapshot) before they touch production.
Honest trade-offs
More environments mean more infrastructure to pay for and maintain, and there's a real temptation to let staging rot until it no longer matches prod, at which point it gives false confidence. Keep staging honest: same artifact, same engine versions, regularly refreshed. If you're a solo developer on a tiny project, a single staging environment plus solid local dev may be enough; don't build a five-environment pipeline for a side project.
Conclusion
Good environment management is mostly discipline: one build artifact, config in the environment, real parity in staging, separate databases per environment, and promotion via reviewable merges. Get those right and most bugs die in staging instead of paging you at 2am.
PandaStack makes per-environment projects cheap by auto-wiring a separate managed database into each one, so staging and prod stay cleanly isolated. Set up your first staging project on the free tier at https://dashboard.pandastack.io.
References
- The Twelve-Factor App, config: https://12factor.net/config
- The Twelve-Factor App, dev/prod parity: https://12factor.net/dev-prod-parity
- Expand/contract migration pattern: https://martinfowler.com/bliki/ParallelChange.html
- GitHub flow: https://docs.github.com/en/get-started/using-github/github-flow