IAnswerable

Configuration That Scales Past One Environment

06 Mar 2026

Twelve-factor config is one rule: config lives in the environment, not in code. Everyone agrees with this in principle and then hardcodes a database URL “just for local dev” anyway. Here’s what it actually looks like once you have more than one environment.

The rule and why it exists

If your database URL, API keys, or feature flags are in a committed file, changing them means a code deploy. That’s backwards — config should change independently of code, and the same build artifact should run correctly in staging and production with nothing but different environment variables.

// wrong: config baked into code
const dbUrl = 'postgres://user:pass@prod-db.internal:5432/app'

// right: config read from environment
const dbUrl = process.env.DATABASE_URL
if (!dbUrl) throw new Error('DATABASE_URL is required')

That throw matters. Fail loudly at startup if required config is missing — don’t let the app boot into a broken state and fail mysteriously on the first request that needs the missing value.

Local dev: .env files, never committed

# .env (gitignored)
DATABASE_URL=postgres://localhost:5432/app_dev
STRIPE_SECRET_KEY=sk_test_xxx
# .gitignore
.env
.env.*
!.env.example

Commit .env.example with the keys but no values, so a new engineer knows what to set without guessing:

# .env.example
DATABASE_URL=
STRIPE_SECRET_KEY=

One build, many environments

The build artifact itself — the Docker image, the compiled bundle — should be identical across staging and production. What differs is the environment variables injected at runtime, not the code inside the image.

# the image has no environment-specific values baked in
FROM node:20-slim
COPY . .
RUN npm ci --omit=dev
CMD ["node", "server.js"]
# staging
docker run -e DATABASE_URL=$STAGING_DB_URL -e NODE_ENV=staging myapp:v1.4.0

# production — same image tag
docker run -e DATABASE_URL=$PROD_DB_URL -e NODE_ENV=production myapp:v1.4.0

If you’re rebuilding the image per environment with different values compiled in, you’ve lost the guarantee that what you tested in staging is what ships to production — a rebuild is a new artifact, not the one you validated.

Validate config shape at startup, not at first use

const required = ['DATABASE_URL', 'STRIPE_SECRET_KEY', 'REDIS_URL']
const missing = required.filter((key) => !process.env[key])
if (missing.length) {
  console.error(`Missing required env vars: ${missing.join(', ')}`)
  process.exit(1)
}

This turns “the app is up but every request to /checkout errors” into “the app never started, and the log says exactly why” — a much shorter debugging session.

What doesn’t belong in environment variables

Not everything is config. Business logic constants that never change per environment — tax rate calculation rules, a fixed list of supported currencies — belong in code, not env vars. Twelve-factor config is for values that differ between deploys (database URLs, API keys, feature flags), not a mandate to externalize every constant in your codebase into an env var nobody can find.

What to skip

Skip a config management service (Consul, etcd, a parameter store) until you have more than a handful of services that need to share config dynamically at runtime. For one app across two or three environments, .env files locally and injected environment variables in deploy are the whole solution — no extra infrastructure required.