Validating Config

Validating Configuration with Zod

Loading a .env file only gets strings into memory. Zod 4.6.5 44,027 (github.com/colinhacks/zod (https://github.com/colinhacks/zod 44,027 ), npm 2,036 i zod, MIT) turns them into a typed, validated object and refuses to start when one is wrong — the same library Express.js uses for request bodies. Write the schema once, in the only module that reads process.env.

config.mjs — parse the environment once, then freeze itJavaScript
import { z } from 'zod';
const Env = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  MONGODB_URI: z.string().startsWith('mongodb'),
  JWT_SECRET: z.string().min(32, 'use at least 32 characters'),
  ADMIN_EMAIL: z.email(),
  ENABLE_SIGNUPS: z.stringbool().default(false),
});
const parsed = Env.safeParse(process.env);
if (!parsed.success) {
  console.error('Invalid configuration:');
  for (const issue of parsed.error.issues)
    console.error('  ' + issue.path.join('.') + ': ' + issue.message);
  process.exit(1);
}
const d = parsed.data;
export const config = Object.freeze({
  env: d.NODE_ENV, port: d.PORT, mongoUri: d.MONGODB_URI,
  jwtSecret: d.JWT_SECRET, signupsEnabled: d.ENABLE_SIGNUPS,
});

Three helpers carry the load. z.coerce.number() runs Number() before the numeric checks, so PORT=8080 becomes the number 8080 and PORT=eighty fails instead of quietly producing NaN. z.stringbool() maps "true", "1", "yes" and "on" to true and their opposites to false. safeParse returns a result instead of throwing, leaving you in control of the exit code.

main.mjs — every other module imports the frozen objectJavaScript
import { config } from './config.mjs';
console.log(config.port, typeof config.port, config.signupsEnabled, Object.isFrozen(config));

Run it against a valid file, then against one with a Postgres 1,289 URL, a seven-character secret, an address with no @, and a port above the 16-bit range. Zod reports every problem at once, so one restart tells you everything wrong with the deployment.

Output of 75
$ node --env-file=.env.good main.mjs
8080 number true true
$ node --env-file=.env.bad main.mjs
Invalid configuration:
  PORT: Too big: expected number to be <=65535
  MONGODB_URI: Invalid string: must start with "mongodb"
  JWT_SECRET: use at least 32 characters
  ADMIN_EMAIL: Invalid email address

That failure is the point: the process exits with status 1, so the container never passes its readiness probe and never receives traffic, while one that starts with port: NaN serves errors until someone reads the logs. Zod 4 also ships z.prettifyError(parsed.error), which renders the same issues as an indented tree.