NodeJSConfig Best Practices

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

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, immutable
Validate all config at boot and crash immediately on anything missing or malformed — never discover a bad value mid-request
The most important config practice is **validate everything at startup and fail fast**. Without it, a missing `DATABASE_URL` or a misspelled variable is `undefined` that slips through until it triggers a confusing crash deep in a request handler — possibly hours after deploy, possibly only on a rarely-hit code path. Instead, define a **schema** of every variable the app requires (with types, ranges, and which are mandatory) and parse `process.env` **once at boot** with [Zod](/nodejs/zod) (or [Joi](/nodejs/joi), or `envalid`). If anything is missing or malformed, **log a clear message and `process.exit(1)` immediately** — a process that can't be correctly configured should refuse to start, not limp along and fail unpredictably later. This turns "mysterious 3am production error" into "the deploy failed instantly with: JWT_SECRET must be at least 32 characters." Validation also **coerces** (string → number/boolean) so the rest of your code gets real types.
Centralize — one config module

TS
// ❌ 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: string
Read `process.env` in exactly one module; the rest of the app imports a typed `config` object — never `process.env` directly
Access `process.env` in **exactly one place** — your config module — and have the rest of the application import the resulting validated `config` object. This single-source-of-truth approach pays off several ways: there's **one file** that documents every setting the app uses (invaluable for onboarding and ops); values are already **validated and coerced** (no `Number(process.env.PORT)` repeated everywhere, no string/number bugs); access is **typed and autocompleted** (`config.PORT` is a `number`, and a typo is a compile error rather than `undefined`); and it's **testable** (swap the config object instead of mutating global `process.env`). Forbid raw `process.env` reads outside the config module — an ESLint rule can enforce it — so the discipline doesn't erode as the team grows.
Never log or expose secrets

JS
// ❌ 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
})
Never log the whole config object or send secrets to the client — redact credentials in logs, error reports, and responses
Validated config commonly *contains* secrets, so handle it like the sensitive data it is. **Never log the entire config object** (`console.log(config)`) — it dumps `JWT_SECRET`, database passwords, and API keys straight into your [logs](/nodejs/logging-intro) and log aggregator, where they're far more exposed than in a secrets store. When logging startup config for diagnostics, **allowlist the safe fields** (env, port, feature flags) and **redact** anything sensitive (mask the password in a connection string). The same applies to **error reporting** ([diagnostic reports](/nodejs/diagnostics), Sentry, stack traces) — scrub secrets before sending — and to **API responses and the client**: server-side secrets must never be serialized into a response or shipped to the browser. Treat every secret as something that should appear only at the exact point of use, never in observability surfaces.
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.env directly 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.env is 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.example is your contract.

Validate, centralize, type, default safely, protect secrets, freeze — config done right is boring, explicit, and fails loud
Good configuration management is unglamorous but high-leverage: it converts a whole class of mysterious runtime failures into instant, obvious startup errors, and a whole class of security incidents into "the secret was never in a place it could leak." The pattern is consistent regardless of size — **validate** the environment against a schema at boot, **centralize** access behind one typed module, **coerce** to real types with **safe defaults** for the harmless values and hard requirements for the critical ones, **never commit or log** secrets, and **freeze** the result so nothing mutates it later. Pair the validation schema with a committed [`.env.example`](/nodejs/dotenv) and you have a self-documenting, fail-loud configuration system that scales cleanly from a side project to a production service.
Next
On to the tools that smooth your daily workflow, starting with auto-restart: [Auto-Restart with nodemon](/nodejs/nodemon).