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