Configuration from the Environment
Read every environment variable once at startup, convert it, and refuse to start when a required one is missing.
process.loadEnvFile([path]) // path defaults to './.env'An environment variable is a NAME=value pair the operating system hands to a process when it starts. Node exposes them as process.env. It is how a container gets its database address without that address being written into the image, so one image can run in development and in production unchanged.
Read once, at the top
Collect every variable in one module that runs at startup, convert each value, and export a frozen object. Sprinkling process.env.X through the codebase means a typo in a name becomes undefined deep inside a request, hours later. Reading once turns the same typo into a crash on line one.
Everything is a string
Values are strings or undefined. Number(process.env.PORT) and an explicit comparison such as process.env.DEBUG === 'true' do the converting. Assigning a number to process.env silently converts it to a string, and that is still allowed: the documentation-only deprecation DEP0104 covers only values that are not a string, boolean or number, so assigning an object or an array is the case that may throw one day.
Defaults, short and long
These two lines are the same thing, and both fall back only when the name is absent:
const { LOG_LEVEL = 'info' } = process.env;
const LOG_LEVEL = process.env.LOG_LEVEL ?? 'info';.env files without a package
Node reads them itself. node --env-file=.env server.js arrived in Node 20.6.0 and throws if the file is absent; --env-file-if-exists (Node 22.9.0) does not. process.loadEnvFile(path) (Node 20.12.0, default './.env') does the same from code, and util.parseEnv(content) parses the text without touching process.env. A value already in the real environment wins over the file.
Example
When to use it
- Run one image in development and production with only the environment differing.
- Make a deploy that is missing DATABASE_URL crash at startup rather than on the first request.
- Give a new contributor a .env.example they can copy and run with --env-file.
- Keep a database password out of the image and out of version control.
More examples
The config module the rest of the app imports
The import of config.js runs before any request is served, so a missing DATABASE_URL throws during startup and the container exits with a clear message. Object.freeze stops a later module from quietly rewriting a value that was read once on purpose.
// config.js
function required(name) {
const value = process.env[name];
if (value === undefined || value === '') {
throw new Error(`${name} is not set`);
}
return value;
}
export const config = Object.freeze({
port: Number(process.env.PORT ?? 3000),
databaseUrl: required('DATABASE_URL'),
redisUrl: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
logLevel: process.env.LOG_LEVEL ?? 'info',
// A flag is a string comparison, never a truthiness check:
// every non-empty string, including 'false', is truthy.
verboseErrors: process.env.VERBOSE_ERRORS === 'true',
});
// server.js
import { config } from './config.js';
const pool = new Pool({ connectionString: config.databaseUrl });
server.listen(config.port, '0.0.0.0');Three ways to load a .env file, and which wins
Use this in development so nobody has to export variables by hand, and rely on real environment variables in production. The precedence rule is the useful part: the file is a default, the environment is the override.
# .env
# PORT=3000
# DATABASE_URL=postgres://app:[email protected]:5432/app
# Node 20.6.0 and later. Throws if .env is missing.
node --env-file=.env server.js
# Node 22.9.0 and later. Silent when the file is absent - right for containers.
node --env-file-if-exists=.env server.js
# Several files: later ones override earlier ones.
node --env-file=.env --env-file=.env.local server.js
# A real environment variable still beats the file.
PORT=8080 node --env-file=.env server.js # PORT is 8080Reading a file without mutating process.env
Keeping a committed .env.example as the list of names the service needs, then diffing it against the live environment, catches the variable somebody forgot to add to the deploy. parseEnv is the right tool because it returns data instead of changing global state.
import { readFileSync } from 'node:fs';
import { parseEnv } from 'node:util';
// parseEnv takes the text of a .env file and returns a plain object.
// Available since Node 20.12.0.
const text = readFileSync('.env.example', 'utf8');
const example = parseEnv(text);
// Startup check: does the real environment cover every documented name?
const undocumented = Object.keys(example).filter((name) => !(name in process.env));
console.log('declared in .env.example but not set:', undocumented.sort());
// process.loadEnvFile() is the other half: it reads a file INTO process.env.
// process.loadEnvFile('./.env');
Discussion