Config Best Practices
Reading process.env.X scattered across your codebase works, but it's fragile: a typo'd variable name is undefined until it crashes mid-request, every value is an un-coerced string, and there's no single place to see what the app needs. The professional pattern is to validate all configuration once at startup, into a single typed, frozen config object that the rest of the app imports. This page covers that pattern — validate-and-fail-fast, centralizing config, never logging secrets, type safety, and the rules that keep configuration safe and maintainable as the app grows.
Validate at startup — fail fast
src/config.ts
import { z } from 'zod'
// Describe every variable the app needs, with types, coercion, and defaults:
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000), // coerce string → number
DATABASE_URL: z.string().url(), // REQUIRED — no default
JWT_SECRET: z.string().min(32), // REQUIRED, min length
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
})
// Parse process.env ONCE. If anything is missing/invalid, throw and crash NOW:
const parsed = schema.safeParse(process.env)
if (!parsed.success) {
console.error('❌ Invalid configuration:', parsed.error.flatten().fieldErrors)
process.exit(1) // refuse to start with bad config
}
export const config = Object.freeze(parsed.data) // typed, validated, immutableCentralize — one config module
// ❌ process.env reads scattered everywhere — untyped, unvalidated, hard to audit:
function connect() { return db(process.env.DATABASE_URL) }
function sign() { return jwt(payload, process.env.JWT_SECRET) }
// ✅ Import the single validated config object — typed and autocompleted:
import { config } from './config'
function connect() { return db(config.DATABASE_URL) }
function sign() { return jwt(payload, config.JWT_SECRET) } // config.JWT_SECRET: stringNever log or expose secrets
// ❌ Dumping config logs secrets straight into your log aggregator:
console.log('Config:', config) // leaks JWT_SECRET, DATABASE_URL password...
// ✅ Log only non-sensitive values, and redact the rest:
console.log('Starting with', {
env: config.NODE_ENV,
port: config.PORT,
database: config.DATABASE_URL.replace(/:\/\/.*@/, '://***@'), // hide credentials
})The config checklist
Validate all config at startup and crash on anything missing or invalid — fail fast with a clear message.
Centralize access in one typed config module; never read
process.envdirectly elsewhere.Coerce and type values (env vars are strings) — expose
number/boolean/enums, not raw strings.Provide safe defaults for non-secret, non-critical values; require secrets and critical values explicitly (no default).
Never commit secrets —
.envis git-ignored, real secrets come from a secrets manager in production.Never log or expose secrets — redact in logs, error reports, and responses.
Freeze the config object (
Object.freeze) so it can't be mutated at runtime.Document every variable — the schema plus a committed
.env.exampleis your contract.