Warming up the neural circuits...
By the end of this chapter you will:
The 12-factor principle "store config in the environment" exists because the same binary must run in dev, staging, and prod with different secrets. Hardcoded config = leaked secrets and risky deploys.
| Belongs in code | Belongs in env |
|---|---|
| URL paths | DB connection strings |
| rules | keys (Stripe, OpenAI) |
| Status codes | signing secrets |
| Default page sizes | Feature flags (sometimes) |
| Algorithm choices | Log levels |
Rule of thumb: if it differs across environments, it's config. If a security review would flag it, it's a secret.
.env is a flat file at the project root:
DATABASE_URL=postgresql://postgres:secret@localhost:5432/app
JWT_SECRET=dev-secret-do-not-use-in-prod
LOG_LEVEL=debug
PORT=3000Load it once at the top of your entry file:
import 'dotenv/config';
// rest of imports...That populates process.env. Never commit .env. Add it to .gitignore immediately:
.env
.env.*.localCommit a .env.example with empty / values so teammates know what to set.
This is the one trick most apps skip. Validate env vars when the app starts. If something's missing, crash before serving the first request — not at 3am when one specific code path runs.
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z
Now anywhere in the app:
import { config } from './config';
const port = config.PORT; // typed as numberThree wins: typed, validated, no process.env.X strewn through the codebase.
Three places config can come from:
.env — local dev defaults.Order: runtime env > .env > code defaults. dotenv won't overwrite already-set values, so production envs always win.
For environment-specific behavior:
if (config.NODE_ENV === 'production') {
// turn on stricter cookie settings, etc.
}Avoid scattering if (env === ...) everywhere. Instead, branch once in config:
export const config = {
...result.data,
cookies: result.data.NODE_ENV === 'production'
? { secure: true, sameSite: 'strict' as const }
: { secure:
.env is fine for dev. Production secrets should live in:
The pattern is identical: the app reads process.env.X; the platform sets X from the secret store. Your code doesn't care.
Secrets must be rotatable. Hardcoded secrets are not. Test that you can rotate a secret without downtime:
JWT_SECRET_NEW).We'll cover this in L3.
| Mistake | Why it's wrong | What to do |
|---|---|---|
.env committed to git | Secrets on GitHub forever (yes, even after deletion — git history) | .gitignore + git filter-branch if it slipped |
Lazy process.env.X reads | Typos = undefined at runtime | Validate once, import typed config |
| Same JWT_SECRET in dev and prod | Local key works against prod | Different secret per env |
| Hardcoded "isProduction" booleans | Drift over time | Read NODE_ENV once |
50-line .env no docs | Onboarding takes a day | .env.example + comments |
Production. Print which env vars are loaded at startup (names only, never values). Helps diagnose "why is this env different from that env."
Security. Never log env vars. Never put them in error messages. Never serialize the config object into a response.
.env.example listing required vars.config.ts that validates with Zod.JWT_SECRET without downtime — sign with the new, verify with either.Config in the environment, validated once at boot, accessed through a typed config object. .env for dev, secret manager for prod. Never commit .env. Layer your config so prod overrides dev overrides defaults.
.env in .gitignore, .env.example checked in..env to git?