Environment & Configuration

Node.js Intermediate — Environment & Configuration

Node.js Intermediate

Chapter 6 of 8  ·  Environment & Configuration

Environment & Configuration

Configuration that changes between environments — database credentials, API keys, port numbers, feature flags — should never be hardcoded. The standard approach is environment variables: values injected into the process at startup, separate from the code. This chapter covers how to load, validate, and structure them cleanly, and what to do with the secrets that are too sensitive to put anywhere near source control.

process.env — The Foundation

Every environment variable the OS passes to the process is available on process.env as a string property. This is available in Node without installing anything.

// Environment variables are always strings console.log(typeof process.env.PORT); // 'string', even if you set PORT=3000 // Must coerce to the correct type yourself const port = Number(process.env.PORT) || 3000; const debug = process.env.DEBUG === 'true'; const timeout = parseInt(process.env.TIMEOUT_MS, 10); // Reading on the command line (Linux/Mac) // PORT=4000 NODE_ENV=production node server.js // Reading on Windows PowerShell // $env:PORT=4000; node server.js

dotenv — Loading .env Files

In development you don't want to set variables in the terminal every time. dotenv reads a .env file and loads its contents into process.env before your code runs.

npm install dotenv

.env

NODE_ENV=development PORT=3000 DB_HOST=localhost DB_USER=root DB_PASSWORD=mysecretpassword DB_NAME=my_app JWT_SECRET=change-me-in-production-use-64-random-chars SMTP_HOST=smtp.mailtrap.io SMTP_PORT=587
// Load as early as possible — top of your entry point (server.js / index.js) require('dotenv').config(); // Only call config() once. After that, process.env is populated. const app = require('./app'); // Node 20.6+ has built-in .env loading — no package needed // node --env-file=.env server.js

💡 dotenv Doesn't Overwrite Existing Variables

If PORT is already set in the shell environment, dotenv leaves it alone. This means your CI/CD or hosting platform's real values always win over the .env file — which is exactly what you want.

To force overwriting: require('dotenv').config({ override: true }) — but you rarely need this.

The .env Files You Need in Every Project

.env

Your local development values. Real credentials for your local database, dev API keys, etc. Never committed to git.

Add to .gitignore: .env

.env.example

A template with all required variable names but no real values. Committed to git so teammates know what to set. Each developer copies it to .env and fills in their own values.

.env.test

Values for your test suite — typically points at a test database, disables email sending, uses a fixed JWT secret. Loaded by dotenv.config({ path: '.env.test' }).

.env.production

Usually not used — production values come from the hosting platform's environment (Render, Railway, AWS, etc.), not a file. If you do use one, never commit it.

.env.example (safe to commit — no real secrets)

# Copy this file to .env and fill in your values NODE_ENV=development PORT=3000 # Database DB_HOST=localhost DB_USER= DB_PASSWORD= DB_NAME= # Auth — generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" JWT_SECRET= # Email (use Mailtrap in dev: https://mailtrap.io) SMTP_HOST= SMTP_PORT=587 SMTP_USER= SMTP_PASS=

Centralising Config — The config.js Pattern

Scattering process.env.SOMETHING throughout the codebase makes the app hard to understand and hard to test. A single config.js module collects everything in one place, coerces types, applies defaults, and exports a plain object the rest of the app imports.

config.js

require('dotenv').config(); const config = { env: process.env.NODE_ENV ?? 'development', port: Number(process.env.PORT) || 3000, db: { host: process.env.DB_HOST ?? 'localhost', user: process.env.DB_USER ?? 'root', password: process.env.DB_PASSWORD ?? '', name: process.env.DB_NAME ?? 'app', poolSize: Number(process.env.DB_POOL_SIZE) || 10, }, jwt: { secret: process.env.JWT_SECRET, expiresIn: process.env.JWT_EXPIRES_IN ?? '7d', }, smtp: { host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT) || 587, user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, }, isDev: process.env.NODE_ENV !== 'production', isProd: process.env.NODE_ENV === 'production', }; module.exports = config;
// Anywhere in the app — clean, typed, no process.env noise const { db, jwt, port } = require('./config'); const pool = mysql.createPool({ host: db.host, user: db.user, password: db.password, database: db.name, }); app.listen(port, () => console.log(`Listening on port ${port}`));

Validation at Startup — Fail Fast

A missing JWT_SECRET discovered at runtime when someone tries to log in is far worse than the app refusing to start. Validate required variables immediately after loading — before the server binds to a port.

config.js (with validation)

require('dotenv').config(); function requireEnv(name) { const value = process.env[name]; if (!value) throw new Error(`Missing required environment variable: ${name}`); return value; } function requireEnvInt(name, min, max) { const n = parseInt(requireEnv(name), 10); if (isNaN(n)) throw new Error(`${name} must be an integer, got: ${process.env[name]}`); if (min !== undefined && n < min) throw new Error(`${name} must be >= ${min}`); if (max !== undefined && n > max) throw new Error(`${name} must be <= ${max}`); return n; } const config = { port: Number(process.env.PORT) || 3000, db: { host: process.env.DB_HOST ?? 'localhost', user: requireEnv('DB_USER'), password: requireEnv('DB_PASSWORD'), name: requireEnv('DB_NAME'), }, jwt: { secret: requireEnv('JWT_SECRET'), expiresIn: process.env.JWT_EXPIRES_IN ?? '7d', }, }; // Throws synchronously at import time if a required var is missing // Node prints the error and exits before the server ever starts module.exports = config;

💡 Schema Validation with zod or envalid

For larger projects, dedicated validation libraries give you better error messages and less boilerplate. envalid is purpose-built for environment variables and produces a clean summary of every missing or invalid variable at once, rather than stopping at the first error.

npm install envalid

const { cleanEnv, str, port, num } = require('envalid');
const env = cleanEnv(process.env, {
  JWT_SECRET: str({ docs: 'Generate with crypto.randomBytes(64)' }),
  PORT: port({ default: 3000 }),
  DB_NAME: str(),
});

NODE_ENV — The Standard Environment Flag

Many libraries (Express, React, Sequelize) behave differently based on NODE_ENV. Keep to the three standard values:

NODE_ENVMeaningTypical effects
development Local dev machine Verbose error messages, stack traces in responses, hot reload, dotenv loaded
test Test runner (Jest, Mocha) Test database, disabled email, fixed JWT secret, extra logging suppressed
production Live environment Generic error responses, no stack traces to clients, performance optimisations enabled
// Guarding behaviour on NODE_ENV const { isProd, isDev } = require('./config'); app.use((err, req, res, next) => { console.error(err); res.status(err.status || 500).json({ error: err.message, // Only expose stack in development — never leak internals to clients stack: isDev ? err.stack : undefined, }); });

Secrets Management in Production

In production, the hosting platform is the source of truth for secrets — not a file on disk. The goal is: no secret ever sits in source control, a log file, or an unencrypted config file.

Platform env vars

Render, Railway, Fly.io, Heroku, and similar platforms let you set environment variables in their dashboard or CLI. They're injected into process.env at runtime — nothing to manage on disk.

Secrets managers

AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager — fetch secrets at startup via an API call and put them in your config object. Supports rotation without redeployment.

Never log secrets

Avoid console.log(config) or logging process.env. Even in development, log output often ends up in monitoring systems. Log the names of variables that were loaded, not their values.

Short-lived credentials

Prefer API keys and database users scoped to the minimum access they need. If a secret leaks, limited permissions reduce the blast radius. Rotate secrets on a schedule regardless.

🔴 Things That Leak Secrets

Committing .env — once pushed, assume the secret is compromised even after you delete it. Git history is permanent. Rotate immediately.

Logging process.env or config objects — structured loggers and monitoring agents often ship everything they receive. Mask or omit sensitive fields explicitly.

Printing errors verbatim to clients — a database error message can reveal the DB host, user, or schema. Catch at the boundary and return a generic message.

Storing secrets in package.json scripts"start": "JWT_SECRET=abc123 node server.js" puts the secret in source control. Use an env var or dotenv instead.

Putting It Together — A Minimal Config Module

// config.js — the complete pattern require('dotenv').config(); function req(name) { const v = process.env[name]; if (v === undefined || v === '') throw new Error(`Missing env var: ${name}`); return v; } const opt = (name, def) => process.env[name] ?? def; const num = (name, def) => Number(process.env[name] ?? def); module.exports = { env: opt('NODE_ENV', 'development'), port: num('PORT', 3000), isProd: opt('NODE_ENV') === 'production', db: { host: opt('DB_HOST', 'localhost'), user: req('DB_USER'), password: req('DB_PASSWORD'), name: req('DB_NAME'), poolSize: num('DB_POOL_SIZE', 10), }, jwt: { secret: req('JWT_SECRET'), expiresIn: opt('JWT_EXPIRES_IN', '7d'), }, };

⚠️ Common Mistakes

Calling dotenv.config() in library code — only the application entry point should call dotenv.config(). Libraries and helper modules just read process.env; calling config() from a module can cause side-effects when the module is imported.

Coercing booleans as truthy stringsprocess.env.FLAG = 'false' is the string 'false', which is truthy in JavaScript. Always compare with === 'true' rather than using the value directly in a boolean context.

Checking NODE_ENV with loose equalityprocess.env.NODE_ENV == 'production' passes even if someone sets it to ' production' (leading space). Always === 'production' after trimming, or use the isProd boolean from config.

Coding Challenges

Challenge 1 — Config Validator

Write a validateConfig(schema, env) function that accepts a schema object and an env object (defaulting to process.env). Each schema entry specifies: required (boolean), type ('string', 'number', 'boolean', 'port'), default (optional), and min/max for numbers. The function should collect all validation errors (not just the first) and throw a single error listing every problem, or return a typed config object if everything is valid.

View sample solution ↗

Challenge 2 — Multi-Environment Config Loader

Build a loadConfig() function that loads environment variables in priority order: (1) actual shell environment, (2) .env.{NODE_ENV} (e.g. .env.test), (3) .env. Higher-priority sources win — shell vars are never overwritten. The function should return the merged config object and log (to stderr, not stdout) which file was loaded. Write it without dotenv — use fs.readFileSync and parse the KEY=VALUE format yourself.

View sample solution ↗

Challenge 3 — Secret Masking Logger

Write a createSafeLogger(sensitiveKeys) function that returns an object with info, warn, and error methods. When passed an object, the logger deep-clones it and replaces the value of any key matching a name in sensitiveKeys (case-insensitive, anywhere in the object tree) with '[REDACTED]' before printing. Strings are passed through unchanged. Include a check that the logger itself is never accidentally called with process.env directly.

View sample solution ↗