Skip to content
Back to Build Tutorials

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 request

Example

// 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.