Configuration & Secrets
Validate config at boot, keep secrets out of code, and use Node's built-in .env support.
Configuration is where sloppy code becomes a 3am outage. Two principles keep you safe: load and validate config once at startup (fail fast if something is missing), and never bake secrets into the image or repo.
Built-in env file support
Modern Node reads .env files natively — node --env-file=.env app.js — so a dotenv dependency is often unnecessary. Real secrets in production still belong in your platform's secret store (Kubernetes Secrets, AWS Secrets Manager, Vault), injected as env vars at runtime.
// config.js — parse and validate ONCE, export a frozen object.
function required(name) {
const v = process.env[name];
if (!v) throw new Error(`Missing required env var: ${name}`);
return v;
}
module.exports = Object.freeze({
port: Number(process.env.PORT ?? 3000),
databaseUrl: required('DATABASE_URL'),
nodeEnv: process.env.NODE_ENV ?? 'development',
});Fail fast, and type your config
An app that boots with a missing DATABASE_URL and only crashes on the first request that touches the DB is far worse than one that refuses to start. Validate the whole schema up front — a small zod/envalid schema turns "undefined is not a function" into "DATABASE_URL is required".
Example
// Boot-time config validation with a schema, redaction for safe logging,
// and clear, aggregated error messages so misconfig fails loudly and once.
import { z } from 'zod';
const Schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
const parsed = Schema.safeParse(process.env);
if (!parsed.success) {
// One clear report of everything wrong, then exit non-zero.
console.error('Invalid configuration:');
for (const issue of parsed.error.issues) {
console.error(` ${issue.path.join('.')}: ${issue.message}`);
}
process.exit(1);
}
export const config = Object.freeze(parsed.data);
// Safe to log: never prints secrets.
export function describeConfig() {
const redact = (s) => (s ? s.replace(/:\/\/([^@]+)@/, '://***@') : s);
return { env: config.NODE_ENV, port: config.PORT, db: redact(config.DATABASE_URL) };
}When to use it
- A SaaS platform uses a layered config approach where defaults from `config/default.json` are deep-merged with environment-specific overrides from `config/production.json`.
- A twelve-factor app stores all config in environment variables and validates them at startup with `zod` so misconfigured deploys fail fast with clear errors.
- A CLI tool uses `cosmiconfig` to search for user config in `package.json`, `.myrc`, or `myconfig.js` so users can choose their preferred format.
More examples
Layered config with env override
Merges a base config object with environment-specific overrides and then allows individual env var overrides.
// config/index.js
const base = {
port: 3000,
logLevel: 'info',
db: { pool: 5, timeout: 3000 },
};
const overrides = {
production: { logLevel: 'warn', db: { pool: 20 } },
test: { logLevel: 'silent', port: 0 },
}[process.env.NODE_ENV] || {};
module.exports = { ...base, ...overrides,
port: parseInt(process.env.PORT, 10) || base.port };Validate config with zod
Uses `zod` to parse and validate `process.env` at startup, throwing descriptive errors for missing or malformed values.
const { z } = require('zod');
require('dotenv').config();
const Config = z.object({
PORT: z.coerce.number().int().min(1).max(65535),
DB_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
const config = Config.parse(process.env);
console.log('Port:', config.PORT);Load config with the config package
Uses the `config` npm package which automatically deep-merges default and environment-specific JSON config files.
// config/default.json: { "server": { "port": 3000 } }
// config/production.json: { "server": { "port": 8080 } }
const config = require('config');
const port = config.get('server.port');
console.log('Port:', port);
// 3000 in dev, 8080 in production (NODE_ENV=production)
Discussion