Environment Config
Load, validate, and layer env vars so the app fails fast on missing config.
Why
Undefined env vars are the #1 cause of 'works locally, breaks in prod' — the var exists in your .env but not in the platform's dashboard, and the app discovers this with a 500 at 3am. A validation layer (zod, envalid, or pydantic) turns this into a startup crash with a clear message, so the deploy fails in CI instead of in prod. Layering (defaults < .env < .env.local < platform) lets you keep secrets out of git while still sharing non-secret config with the team. Without this, every dev maintains a personal .env that drifts, and onboarding takes a day of 'what vars do I need?'.
When
As soon as the app reads its first env var. Takes 20-40 min with zod. Skip only for pure static sites with zero config.
How
1. Define a schema with zod, listing every var with type + default + description
- const schema = z.object({ DATABASE_URL: z.string().url(), PORT: z.coerce.number().default(3000) })
2. Parse process.env at startup, export a typed singleton
- const env = schema.parse(process.env) — throws on first missing var
- Call this in one place (src/env.ts); everyone imports from there
3. Create .env.example with every key + a comment, commit it
- Never commit .env; add it to .gitignore on commit 1
4. Layer precedence: .env.defaults < .env < .env.local < platform vars
- Use dotenv-flow or Next.js built-in layering
- .env.local holds dev secrets (gitignored); platform holds prod secrets
5. Fail fast: call env parse at module top-level, not inside a request handler
- A missing var should crash the server on boot, not on the first requestExample
// src/env.ts
import { z } from "zod";
const schema = z.object({
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
NEXTAUTH_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
});
export const env = schema.parse(process.env);
// → if DATABASE_URL is missing, app crashes on boot:
// "Invalid input: DATABASE_URL: Required"Common Problems
- ⚠Reading process.env directly in 20 files instead of one module — changing a var name requires a repo-wide search.
- ⚠Treating all vars as strings, so PORT='3000' + 1 gives '30001' instead of 3001 at runtime.
- ⚠Putting secrets in .env and committing it 'just for the team' — the git history now contains a Stripe key forever.
- ⚠Not validating on boot, so the app starts fine and crashes on the first DB query with a cryptic connection error.
Best Practices
- ✓Use z.coerce for numbers and booleans so 'PORT=3000' becomes a real number without manual parseInt.
- ✓Add a CI step that validates .env.example against the schema — catches vars you added to code but forgot to document.
- ✓Never log the entire env object in error handlers — you will leak secrets to your log aggregator.
- ✓Use a secret manager (Doppler, Infisical) for prod instead of pasting values into a platform dashboard — rotation becomes a config change, not a deploy.