Environments: local, staging and production
What actually changes between environments and how to cut down surprises with validated configuration and parity.
The problem
Section titled “The problem”Friday, 6 p.m., deploy. The app starts, the health check passes and ten minutes later the first errors arrive: TypeError: Cannot read properties of undefined. A new environment variable was missing in production. Locally it was in .env; in staging someone had set it by hand; in production, nobody remembered.
This is the most common kind of “it worked there, it broke here”. The code was the same in all three environments. The difference was in the configuration around it.
What actually changes
Section titled “What actually changes”| Dimension | Local | Staging | Production |
|---|---|---|---|
| Data | Small, clean seed | Anonymized or synthetic copy | Millions of rows, messy and historical |
| Scale | 1 user, 1 process | A few users | Real concurrency, several instances |
| Network | Everything on localhost |
Cloud network | Latency, packet loss, CDN, firewall |
| Configuration | .env on disk |
Variables in the provider | Variables + secret manager |
| External dependencies | Mocks or sandbox mode | Sandbox (Stripe test mode) | Real APIs, with real rate limits and outages |
| Permissions | You’re admin of everything | Close to production | Least privilege, restricted IAM |
| Who’s watching | You, looking at the terminal | Logs | Nobody, only alerts |
Some differences are unavoidable (data volume, real traffic). Others are accidental and should be removed: a different Postgres version, SQLite locally with Postgres in production, Node 20 locally and 22 in production, configuration done by hand.
The principle: one artifact, many configurations
Section titled “The principle: one artifact, many configurations”flowchart LR C[Commit] --> B[Single build<br/>image v1.42] B --> S[Staging<br/>staging config] S -->|approved| P[Production<br/>production config]
- Build once and promote the same artifact. If you rebuild for production, what you tested in staging isn’t what goes live.
- Configuration lives in the environment, outside the code. URLs, keys and flags come from environment variables (the “config” principle of the 12-Factor App).
- The code doesn’t ask which environment it’s in. Instead of
if (env === 'production') useRealStripe(), the configuration says which Stripe key to use. Every per-environmentifis a code path that only runs in production, which means it was never tested.
In practice
Section titled “In practice”1. Validate configuration at startup
Section titled “1. Validate configuration at startup”The app should refuse to start if its configuration is incomplete. A deploy that fails its health check is much better than one that breaks ten minutes later in a user’s hands.
import { z } from 'zod'; // zod v4
const schema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), PORT: z.coerce.number().default(3000), DATABASE_URL: z.url(), REDIS_URL: z.url(), STRIPE_SECRET_KEY: z.string().startsWith('sk_'), PUBLIC_BASE_URL: z.url(), // no hardcoded "localhost" in the code LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),});
const parsed = schema.safeParse(process.env);
if (!parsed.success) { // Lists every bad variable at once, not just the first one. console.error('Invalid configuration:', z.flattenError(parsed.error).fieldErrors); process.exit(1);}
export const config = parsed.data;The rest of the code imports config and never reads process.env directly. You get types, defaults and a single place that shows everything the app needs.
2. Document the variables in the repository
Section titled “2. Document the variables in the repository”Commit a .env.example with every key and fake values. The real .env goes in .gitignore.
DATABASE_URL=postgres://linkr:linkr@localhost:5432/linkrREDIS_URL=redis://localhost:6379STRIPE_SECRET_KEY=sk_test_xxxPUBLIC_BASE_URL=http://localhost:30003. Reproduce production dependencies locally
Section titled “3. Reproduce production dependencies locally”Run the same version of the database and cache locally that you run in production:
services: db: image: postgres:17 # same major version as production environment: POSTGRES_USER: linkr POSTGRES_PASSWORD: linkr ports: ['5432:5432'] redis: image: redis:7 ports: ['6379:6379']Containers get a proper treatment in module M20. For now it’s enough to know they’re the cheapest way to get parity.
4. Use staging for what it’s good at
Section titled “4. Use staging for what it’s good at”Staging is for validating integration and deployment: the migration runs, the variables are right, the Stripe webhook arrives, the build starts. It does not prove that the app can take production load or that it works with production data. That’s what load tests (M26), feature flags and gradual rollouts (M22) are for.
Pitfalls
Section titled “Pitfalls”- Staging pointing at production resources. Share one bucket or queue and staging ends up deleting real files. Environments share nothing that holds state.
- Production data on a developer’s machine. That’s a leak waiting to happen and a privacy-law problem (GDPR, LGPD). Use synthetic or anonymized data.
- Configuration done by hand in the provider’s dashboard. Nobody remembers what was changed. Prefer versioned configuration (infrastructure as code, M21).
- What AI tends to generate:
process.env.Xscattered through the code with no validation;http://localhost:3000hardcoded in callback URLs; SQLite “to keep things simple” when production runs Postgres.
Go deeper
Materials in English unless noted.
- Article
- ArticleTwelve-Factor: Dev/prod parity12factor.net
The three gaps between environments (time, people, tools) and how to shrink them.
- Docs
- Docs