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

Example · javascript
// 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.

Example · js
// 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.

Example · js
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.

Example · js
// 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

  • Be the first to comment on this lesson.