In Node.js, every environment variable lives on the process.env object as a string. There is no built-in type coercion, no validation, and no file loading — process.env.PORT returns "3000", not 3000. The dotenv package filled the gap for years, but Node 20.6+ ships a native --env-file flag that eliminates the dependency for many projects. This guide covers how process.env works, every popular approach to loading and validating env vars, cross-platform pitfalls, and the runtime mistakes that cause subtle production bugs.
What is process.env?
process.env is a plain object populated by the operating system when the Node.js process starts. Every value is a string — even numeric ports, boolean feature flags, and JSON blobs. The object is mutable at runtime: you can assign new keys and they become visible anywhere in the same process, but they do not propagate back to the host shell.
// Every value is a string
console.log(typeof process.env.PORT); // "string"
console.log(process.env.PORT); // "3000"
// Direct comparison with a number silently fails
if (process.env.PORT === 3000) {
// Never reached — "3000" !== 3000
}
// Correct: parse to a number first
const port = Number(process.env.PORT) || 3000;
// process.env is mutable
process.env.MY_FLAG = 'true';
console.log(process.env.MY_FLAG); // "true"Keys that are not set return undefined, not an empty string. This distinction matters when you use || vs ?? for defaults.
How do you load a .env file with dotenv?
The dotenv package reads a .env file from the project root and merges its key-value pairs into process.env. It does not override variables that already exist in the shell environment, which means real environment variables always take precedence.
npm install dotenv// Load as early as possible — before any other imports that read env vars
import 'dotenv/config';
// Or with options
import dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });
// Override existing shell variables (use with caution)
dotenv.config({ override: true });
// Load from a custom path with encoding
dotenv.config({
path: '/etc/myapp/.env',
encoding: 'latin1',
});For a complete reference on .env file syntax, quoting rules, and multi-line values, see the .env guide.
What is the --env-file flag in Node 20.6+?
Node.js 20.6 introduced the --env-file CLI flag, which loads .env files natively — no third-party package required. Node 21.7+ added support for multiple files and the --env-file-if-exists variant that silently skips missing files.
# Load a single file
node --env-file=.env app.js
# Load multiple files (later files override earlier ones)
node --env-file=.env --env-file=.env.local app.js
# Skip missing files without error (Node 21.7+)
node --env-file-if-exists=.env.local app.jsKey differences from dotenv: the built-in flag loads before any JavaScript executes, supports multi-line values without quotes, and does not perform variable expansion by default. If your project only needs basic file loading and targets Node 20.6+, the built-in flag removes a dependency entirely. Bun auto-loads .env with no flag at all and Deno requires --env-file plus --allow-env — see the Bun vs Deno vs Node comparison for the full runtime breakdown.
Why does NODE_ENV matter?
NODE_ENV is not a Node.js built-in — it is a convention established by Express and adopted universally. Setting it to "production" has concrete effects across the ecosystem:
npm installskipsdevDependencies- Express disables verbose error pages and enables view caching
- Webpack, Vite, and other bundlers enable minification and dead-code elimination when they detect
process.env.NODE_ENV === "production" - Many logging libraries switch to structured JSON output
# Set in the shell before starting your app
NODE_ENV=production node app.js
# Or in a .env file
NODE_ENV=productionNever set NODE_ENV to a custom value like "staging" — many packages only check for "production" and treat everything else as development. Use a separate variable like APP_ENV=staging for your own logic.
How do you add type-safe env var access?
process.env values are typed as string | undefined in TypeScript, which forces null checks everywhere. Two popular libraries solve this with schema validation at startup: fail early instead of hitting an undefined secret in production at 3 AM.
// Using zod
import { z } from 'zod';
const envSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
ENABLE_CACHE: z.coerce.boolean().default(false),
});
// Parse once at startup — throws with clear errors if invalid
export const env = envSchema.parse(process.env);
// Now fully typed: env.PORT is number, env.DATABASE_URL is string
console.log(env.PORT + 1); // works, no type error// Using envalid
import { cleanEnv, str, port, bool } from 'envalid';
export const env = cleanEnv(process.env, {
PORT: port({ default: 3000 }),
DATABASE_URL: str(),
LOG_LEVEL: str({ choices: ['debug', 'info', 'warn', 'error'], default: 'info' }),
ENABLE_CACHE: bool({ default: false }),
});
// env.PORT is number, env.DATABASE_URL is string
// Missing or invalid vars throw immediately with helpful messagesHow do you set env vars cross-platform?
Setting inline environment variables works differently across shells. NODE_ENV=production node app.js works on Linux and macOS but fails on Windows cmd.exe. The cross-env package solves this:
{
"scripts": {
"start": "cross-env NODE_ENV=production node app.js",
"test": "cross-env NODE_ENV=test vitest"
}
}If you only target Unix-like systems (Docker, CI pipelines, Linux/macOS development), cross-env is unnecessary. But for open-source packages or teams with Windows developers, it avoids broken npm scripts.
Do child processes inherit environment variables?
Yes. When Node.js spawns a child process via child_process.spawn() or fork(), the child inherits a copy of the parent's process.env by default. You can override this with the env option:
import { spawn } from 'node:child_process';
// Child inherits all parent env vars by default
spawn('node', ['worker.js']);
// Override specific vars while keeping the rest
spawn('node', ['worker.js'], {
env: { ...process.env, WORKER_ID: '1' },
});
// Provide a completely isolated environment (no inherited vars)
spawn('node', ['worker.js'], {
env: { PATH: process.env.PATH, WORKER_ID: '1' },
});Changes made to process.env after dotenv loads are visible to child processes spawned later, because the inheritance happens at spawn time, not at process start.
What does a config module pattern look like?
The most robust approach is a single config module that loads, validates, and exports typed env vars. Every other module imports from this file instead of reading process.env directly:
// config.ts — single source of truth
import 'dotenv/config';
import { z } from 'zod';
const schema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().min(32),
});
export const config = schema.parse(process.env);
// Usage in other files:
// import { config } from './config.ts';
// app.listen(config.PORT);This pattern centralizes all env access, catches missing variables at startup (not mid-request), and gives you full TypeScript autocompletion. Validate your .env files for syntax errors using our env validator.
What are common mistakes to avoid?
Reading process.env in hot paths
Every process.env.X access calls into the C++ binding layer to read the OS environment. In tight loops or per-request code, this overhead adds up. Read env vars once at startup and cache them in a config object.
// Bad — OS lookup on every request
app.get('/api', (req, res) => {
const secret = process.env.API_SECRET;
// ...
});
// Good — read once, reuse everywhere
const API_SECRET = process.env.API_SECRET;
app.get('/api', (req, res) => {
const secret = API_SECRET;
// ...
});Forgetting that all values are strings
process.env.ENABLE_CACHE === false is always false because the left side is a string. Similarly, process.env.MAX_RETRIES + 1 produces string concatenation like "31" instead of 4. Always explicitly convert types.
// Bug: string "false" is truthy
if (process.env.ENABLE_CACHE) {
// This runs even when ENABLE_CACHE="false"
}
// Fix: compare the string value
const cacheEnabled = process.env.ENABLE_CACHE === 'true';
// Bug: string concatenation
const retries = process.env.MAX_RETRIES + 1; // "31"
// Fix: parse the number
const retries = Number(process.env.MAX_RETRIES) + 1; // 4Loading dotenv too late
If you import modules that read process.env before calling dotenv.config(), those modules see undefined. Always load dotenv as the very first import using import 'dotenv/config' or the --env-file flag.
Committing .env to version control
Add .env and .env.local to your .gitignore. Commit a .env.example with placeholder values instead so new team members know which variables are required.
References
- Node.js docs:
process.env— official reference for the env object and its Windows case-insensitivity quirk. - Node.js CLI:
--env-file— flag docs (stable in v24.10.0, originally landed in v20.6.0). - motdotla/dotenv — source and README for the original
dotenvpackage. - colinhacks/zod — TypeScript-first schema validation, used in the type-safe config example above.
- af/envalid — a smaller, env-specific alternative to zod with built-in port/bool/url validators.
- kentcdodds/cross-env — cross-platform env var setting for npm scripts on Windows
cmd.exe.
Check your .env files for syntax errors with the env validator, or read the full .env guide for syntax details and cross-language usage.