Config & Environment Validation
Load config once, validate it against a schema at boot, and crash immediately if anything required is missing.
The worst config bug is the one that surfaces at 3am when the code finally reaches the route that needs the missing variable. The fix is to fail fast: validate the entire environment at startup, and refuse to boot if it is wrong.
The pattern
- Load
.envin development. On Node 24 you can use the built-in--env-file=.envflag and skip thedotenvdependency entirely. - Parse
process.envthrough a schema (Zod) that coerces types and applies defaults. - Export one frozen
configobject. The rest of the app imports that, neverprocess.envdirectly.
const config = envSchema.parse(process.env); // throws at boot if invalid
export default Object.freeze(config);Why centralize
One validated, typed config object means no scattered process.env.PROT typos silently returning undefined, and a single documented list of everything the service needs to run.
Example
// config.js — the single source of truth for configuration.
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug']).default('info'),
CORS_ORIGINS: z.string().default('http://localhost:5173'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
// Fail loud, fail at boot — never limp along with half a config.
console.error('Invalid environment configuration:');
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const config = Object.freeze(parsed.data);
// Run in dev with: node --env-file=.env server.js (Node 24, no dotenv needed)
// Everywhere else: import { config } from './config.js' — never process.envWhen to use it
- A developer uses the convict library to define a config schema with type coercion and required field validation, failing fast on misconfiguration.
- A team centralises all process.env reads in a single config/index.js module so the rest of the codebase imports typed config values, never raw strings.
- A developer uses NODE_ENV-specific .env files (.env.development, .env.test) loaded by dotenv-flow to keep environment configs cleanly separated.
More examples
Centralised config module
Validates required env vars at startup and exports typed config values so the rest of the app never reads process.env directly.
// config/index.js
require('dotenv').config();
const required = ['DATABASE_URL', 'JWT_SECRET', 'PORT'];
required.forEach(k => { if (!process.env[k]) throw new Error(`Missing: ${k}`); });
module.exports = {
port: Number(process.env.PORT),
db: process.env.DATABASE_URL,
jwt: { secret: process.env.JWT_SECRET, expiresIn: '1h' },
isProd: process.env.NODE_ENV === 'production',
logLevel: process.env.LOG_LEVEL || 'info',
};Environment-specific dotenv files
Shows the dotenv-flow convention for per-environment .env files so running NODE_ENV=test automatically loads .env.test overrides.
# Install dotenv-flow
npm install dotenv-flow
# File structure
# .env (shared defaults, committed)
# .env.development (local dev overrides, gitignored)
# .env.test (test DB URL etc., gitignored)
# .env.production (set via CI/CD secrets, never on disk)Config validation with Joi
Validates the entire process.env object against a Joi schema, applying defaults and throwing a descriptive error on any violation.
const Joi = require('joi');
const schema = Joi.object({
NODE_ENV: Joi.string().valid('development', 'test', 'production').required(),
PORT: Joi.number().port().default(3000),
DATABASE_URL: Joi.string().uri().required(),
JWT_SECRET: Joi.string().min(32).required(),
}).unknown();
const { error, value } = schema.validate(process.env);
if (error) throw new Error(`Config validation failed: ${error.message}`);
module.exports = value;
Discussion