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.
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.
.env
💡 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)
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
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)
💡 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_ENV | Meaning | Typical 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 |
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
⚠️ 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 strings — process.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 equality — process.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.
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.
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.