Skip to content
M01Local is not productionLesson 2 of 4

Environments: local, staging and production

What actually changes between environments and how to cut down surprises with validated configuration and parity.

Goal
List the real differences between your environments, remove the avoidable ones and make the app refuse to start when its configuration is wrong.

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.

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-environment if is a code path that only runs in production, which means it was never tested.

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.

src/config.ts
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.

.env.example
DATABASE_URL=postgres://linkr:linkr@localhost:5432/linkr
REDIS_URL=redis://localhost:6379
STRIPE_SECRET_KEY=sk_test_xxx
PUBLIC_BASE_URL=http://localhost:3000

3. 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:

docker-compose.yml
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.

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.

  • 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.X scattered through the code with no validation; http://localhost:3000 hardcoded in callback URLs; SQLite “to keep things simple” when production runs Postgres.

Go deeper

Materials in English unless noted.

  • Article
    Twelve-Factor: Config12factor.net

    Why configuration belongs in the environment and not in the code.

  • Article
    Twelve-Factor: Dev/prod parity12factor.net

    The three gaps between environments (time, people, tools) and how to shrink them.

  • Docs
    Zodzod.dev

    Docs for the validation library used in the configuration example.

  • Docs
    Docker ComposeDocker

    How to run a database, cache and other local services from a single file.