Most software systems run in multiple environments. Dev, staging, production. Maybe QA. Maybe canary. The configuration differences between them — what hostname, what database, what secrets, what feature flags — are where promotion goes wrong. A change works in staging, blows up in production, and the difference is some config that nobody documented.
Multi-environment promotion is the discipline of moving code through environments in a controlled, predictable way. The good promotion strategies share three properties: same artifact, different config, transparent differences.
The Principle
The artifact does not change between environments. The same Docker image, the same compiled binary, the same package — built once, deployed to dev, staging, and production unchanged.
Configuration differs. Database hostnames, secrets, feature flags, replica counts. These come from the environment, not from the artifact.
Code repository → CI builds artifact (image:abc1234)
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Dev config Staging config Prod config
│ │ │
▼ ▼ ▼
Dev env Staging env Prod env
(image:abc1234)(image:abc1234)(image:abc1234)
The artifact's reproducibility is what makes promotion meaningful. If staging runs image A and production runs image B (even if "both were built from the same commit"), you have introduced an uncontrolled variable.
Configuration Approaches
Three patterns for environment-specific configuration:
Environment variables. Hostname, database URL, feature flags all set as env vars. Simple, framework-agnostic, well-supported.
# Kubernetes deployment, production
env:
- name: DATABASE_URL
valueFrom: { secretKeyRef: { name: prod-db, key: url }}
- name: FEATURE_NEW_DASHBOARD
value: "true"
Config files. A file per environment, loaded based on NODE_ENV or similar. More structured for complex config.
Configuration service. Consul, etcd, AWS Parameter Store, Vault. Dynamic config, no redeploy needed for changes.
For most teams: env vars for simple values, config service for secrets and dynamic values. Config files in source control for non-secret defaults.
What Should Differ Between Environments
Things that genuinely differ:
- Hostnames and endpoints (production uses prod.example.com, staging uses staging.example.com)
- Database connections
- External API credentials (production keys, staging test keys)
- Feature flag values
- Resource limits (production gets more CPU/memory)
- Replica counts
Things that should not differ (but often do):
- Application code (build once, deploy everywhere)
- Database schema (apply migrations consistently)
- Library versions (use the lockfile)
- Build tooling versions
If staging runs Node 18 and production runs Node 20, you have an environment drift problem. Pin the runtime versions and verify in CI.
The Promotion Pipeline
A typical promotion flow:
- Commit to main
- CI builds and pushes the artifact (image:abc1234)
- Dev environment auto-deploys image:abc1234
- Smoke tests run against dev
- Engineer or automation triggers staging deploy of image:abc1234
- Integration tests run against staging
- QA reviews staging
- Engineer or release manager promotes image:abc1234 to production
- Production deploys gradually (canary, then rolling)
Each step is the same artifact, different environment. The variable is configuration, deployed alongside the artifact.
Tooling
For most production setups:
- Argo CD or Flux for Kubernetes promotion
- GitHub Actions / GitLab CI for the orchestration
- Helm or Kustomize for environment-specific config templates
- Vault, AWS Secrets Manager, or similar for secrets
The Argo CD pattern is to have separate Applications per environment, each referencing the same image but different values files.
# argocd-staging.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: myservice-staging
spec:
source:
helm:
valueFiles:
- values-staging.yaml
parameters:
- name: image.tag
value: abc1234
# argocd-production.yaml — same chart, different values
metadata:
name: myservice-production
spec:
source:
helm:
valueFiles:
- values-production.yaml
parameters:
- name: image.tag
value: abc1234 # explicitly set, after promotion
Promotion is updating the tag in one place and committing. The change rolls out automatically.
Drift Detection
Configuration drift is the silent killer. Three patterns to detect it:
Compare environments programmatically. A scheduled job that diffs config between staging and production. Highlights what is different and why.
Drift dashboard. Visualize config differences. Production has 8 env vars; staging has 9 — why?
Renormalization on deploy. Every deploy reconciles environment to declared config. Any out-of-band change gets reverted.
Without drift detection, "fix it in staging without committing" becomes the normal way to fix things. A few weeks later, nobody knows what staging actually has.
Secrets in Multiple Environments
The cleanest pattern: secrets live in a secrets manager, referenced by environment-specific paths.
secrets/
production/
database/url
stripe/api-key
auth/jwt-secret
staging/
database/url
stripe/api-key (different value — test key)
auth/jwt-secret (different value)
The deployment manifest references the path; the actual value is resolved at deploy or runtime.
What to avoid: secrets in environment variables checked into config files. Even encrypted, they are eventually accessible to too many people.
Feature Flag Coordination
Feature flags vary by environment, sometimes by user. The promotion concern: a flag turned on in staging should be deployable to production with the flag in a known state.
The pattern: flag defaults in code, environment-specific overrides in the flag service.
const defaultFlags = {
newDashboard: false,
betaCheckout: false,
};
const flags = await flagService.fetch({
environment: 'production',
defaults: defaultFlags,
});
Production deploys the same code; the flag values come from the flag service. A new flag deploys to production "off" by default and gets turned on through the flag UI, not through code changes.
Database Migrations Across Environments
Migrations are the trickiest part of multi-environment promotion. The constraints:
- Staging should run the migration before production
- The migration should be tested in staging with production-like data
- Production migration should match what was tested in staging
The pattern that works:
- Migration is part of the deploy artifact
- Deploy to staging runs the migration
- Production deploy runs the same migration
The pitfalls:
- Migrations that depend on data only present in production
- Migrations that take dramatically longer in production (different data size)
- Migrations that lock tables for too long under production load
For risky migrations: test against a production-data clone in staging before production.
What Goes Wrong
The classic failures of multi-environment promotion:
- Different config between environments unexpectedly. Staging has a config that production does not. Tests pass; production breaks.
- Hand-edited staging. Someone fixes a problem directly in staging, never propagates the fix. The next promotion regresses.
- Skipped environment. Hot-fix deploys directly to production, bypassing staging. Staging is now out of sync with production.
- Different artifact. "We rebuilt because staging needed a different X." Now you are testing one binary and deploying another.
- Long-lived dev environment. Dev branches accumulate drift. Devs work against versions nobody else has.
The discipline that prevents these is to make all changes through the promotion pipeline. No direct staging edits, no hot-fix-only-in-production, no per-environment rebuilds.
A Practical Setup
For a Laravel + Kubernetes setup:
- Build once. CI builds and pushes image with the commit SHA tag.
- Three environments: dev (auto-deploys main), staging (manual promotion), production (manual promotion with approval).
- Helm chart with values-{env}.yaml for each environment's config.
- Argo CD watches the deploy repo, applies values based on environment.
- Secrets in Vault, mounted via Vault Agent sidecars.
- Drift detection as a scheduled job that compares actual to desired.
This setup typically takes a sprint or two to build from scratch. The payoff is months of fewer "works on staging" incidents.
When the Pattern Is Wrong
Some workloads do not benefit from rigorous multi-environment promotion:
- Static sites with no backend state — production is just a CDN deploy
- Internal tools used by a few people — staging is unnecessary
- Throwaway experiments — promotion overhead exceeds the work value
The discipline scales with the cost of getting it wrong. For systems where production incidents are expensive, invest in the promotion pipeline. For systems where they are not, lighter-weight setups are fine.
Reviewing a promotion pipeline that has drifted and produced staging/production mismatches? We help teams formalize promotion with same-artifact discipline and drift detection. scopeforged.com