env.dev

Node.js Env Variables: process.env, dotenv & --env-file

How to use environment variables in Node.js: process.env, dotenv, the Node 20.6+ --env-file flag, NODE_ENV, type-safe validation with zod.

By env.dev Updated

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 — and Node 20.12+/21.7+ added process.loadEnvFile() — that together eliminate the dependency for many projects. The catch: the built-in parser does not do variable expansion, so a .env that works with dotenv can behave differently under --env-file.

TL;DR

  • Every process.env value is a string — process.env.PORT is "3000", never 3000. Parse numbers and booleans explicitly.
  • Node 20.6+ loads .env natively with node --env-file=.env; Node 20.12+/21.7+ adds the programmatic process.loadEnvFile(). Both went stable (non-experimental) in v24.10.0 and v22.21.0.
  • The built-in parser has no ${VAR} variable expansion and no .env.local cascade — dotenv (with dotenv-expand) still wins when you need either.
  • OS/shell environment variables always take precedence over file values, whether you use --env-file or dotenv.
  • NODE_ENV is a convention, not a built-in; never set it to anything but production, development, or test.

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.

typescript
// 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.

bash
npm install dotenv
typescript
// 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.0 (released August 2023) introduced the --env-file CLI flag, which loads .env files natively — no third-party package required. Passing the flag multiple times has worked since that first release (later files override earlier ones). The --env-file-if-exists variant, which silently skips a missing file instead of throwing, landed later in v22.9.0 — not 21.x, a common documentation error. Both flags went stable (no longer experimental) in v24.10.0 and v22.21.0.

bash
# 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 22.9.0+)
node --env-file-if-exists=.env.local app.js

For a programmatic equivalent, process.loadEnvFile() (added in v20.12.0 and v21.7.0) loads a file at runtime instead of at the CLI. Called with no argument it reads .env from the current working directory and throws if the file is missing — pair it with --env-file-if-exists-style handling by wrapping it in a try/catch:

typescript
// Load .env from the current working directory (throws if missing)
process.loadEnvFile();

// Load a specific file
process.loadEnvFile('./config/.env.production');

// Tolerate a missing optional file (no --env-file-if-exists equivalent in code)
try {
  process.loadEnvFile('.env.local');
} catch (err) {
  // file is optional — ignore ENOENT
}

Key differences from dotenv: the built-in flag loads before any JavaScript executes, supports multi-line values, and does not perform variable expansion. 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 (see the Bun environment variables guide) and Deno requires --env-file plus --allow-env — see the Bun vs Deno vs Node comparison for the full runtime breakdown.

Built-in --env-file vs dotenv: which should you use?

Reach for the built-in loader when you target Node 20.6+ and your .env has no variable references; reach for dotenv when you need expansion, an .env.local cascade, or support for Node versions older than 20.6.

CapabilityBuilt-in --env-file / loadEnvFiledotenv
Extra dependencyNone (built into Node 20.6+)npm package
Variable expansion (${HOST})NoVia dotenv-expand
Loads before any JS runsYes (with --env-file)No — runs as an import
Multiple files / override orderYes (repeat the flag)Yes (repeat config())
Skip-if-missing--env-file-if-exists (v22.9.0+)Always silent on missing file
Pre-20.6 Node supportNoYes

One subtlety both share: the OS environment wins. If PORT is already exported in the shell, neither --env-file nor dotenv overwrites it unless you explicitly opt in (dotenv.config({ override: true }); the built-in loader has no override option at all).

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 install skips devDependencies
  • 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
bash
# Set in the shell before starting your app
NODE_ENV=production node app.js

# Or in a .env file
NODE_ENV=production

Never 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. See the full NODE_ENV reference for the precedence and edge cases.

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.

typescript
// 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
typescript
// 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 messages

How 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 with 'NODE_ENV' is not recognized as an internal or external command — the fix-it guide covers the same root cause for NODE_NO_WARNINGS, NODE_OPTIONS, and friends. The cross-env package solves it for npm scripts:

json
{
  "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:

typescript
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 reliable 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:

typescript
// 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.

typescript
// 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.

typescript
// 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; // 4

Loading 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. A leaked secret in git history survives a later deletion — the env var security guide covers rotation and history scrubbing.

When should you not use the built-in --env-file?

  • You need variable expansion — if your .env uses DATABASE_URL=postgres://${HOST}/db, the built-in parser stores the literal ${HOST}. Use dotenv with dotenv-expand.
  • You support Node < 20.6 — the flag and process.loadEnvFile() simply do not exist there.
  • A framework already loads env for you — Next.js loads .env, .env.local, and mode-specific files itself, so adding --env-file double-loads. See the Next.js env variables guide for that cascade.

Frequently Asked Questions

How do I read environment variables in Node.js?

Use process.env.KEY, which returns the value as a string, or undefined if the variable is not set. Every value is a string — parse numbers, booleans, and JSON yourself.

What is the --env-file flag in Node.js?

Node.js 20.6.0 added a built-in --env-file flag that loads .env files without any package: node --env-file=.env app.js. It supports comments, quoting, and multi-line values but not variable expansion. --env-file-if-exists (added in v22.9.0) skips a missing file instead of throwing.

What is process.loadEnvFile() and how is it different from --env-file?

process.loadEnvFile([path]) (added in v20.12.0 / v21.7.0) loads a .env file programmatically at runtime instead of via the CLI flag. With no argument it reads .env from the current working directory and throws if the file is missing. Unlike --env-file, it runs after JavaScript has started, so imports evaluated before the call will not see the loaded values.

What is NODE_ENV and why does it matter?

NODE_ENV is a convention (not a Node.js built-in) that signals the runtime environment. Express, React, and bundlers like Vite and Webpack switch behavior on "production". Set it only to "production", "development", or "test" — many packages treat any other value as development.

Does --env-file override real environment variables?

No. If the same variable exists in the OS/shell environment and the .env file, the environment value wins. The built-in loader has no override option; dotenv requires dotenv.config({ override: true }) to flip this.

References

Check your .env files for syntax errors with the env validator, or read the full .env guide for syntax details and cross-language usage.

Was this helpful?

Frequently Asked Questions

How do I read environment variables in Node.js?

Use process.env.KEY, which returns the value as a string, or undefined if the variable is not set. Every value is a string — parse numbers, booleans, and JSON yourself.

What is the --env-file flag in Node.js?

Node.js 20.6.0 added a built-in --env-file flag that loads .env files without any package: node --env-file=.env app.js. It supports comments, quoting, and multi-line values but not variable expansion. --env-file-if-exists (added in v22.9.0) skips a missing file instead of throwing.

What is process.loadEnvFile() and how is it different from --env-file?

process.loadEnvFile([path]) (added in v20.12.0 / v21.7.0) loads a .env file programmatically at runtime instead of via the CLI flag. With no argument it reads .env from the current working directory and throws if the file is missing. Unlike --env-file, it runs after JavaScript has started, so imports evaluated before the call will not see the loaded values.

What is NODE_ENV and why does it matter?

NODE_ENV is a convention (not a Node.js built-in) that signals the runtime environment. Express, React, and bundlers like Vite and Webpack switch behavior on "production". Set it only to "production", "development", or "test" — many packages treat any other value as development.

Does --env-file override real environment variables?

No. If the same variable exists in the OS/shell environment and the .env file, the environment value wins. The built-in loader has no override option; dotenv requires dotenv.config({ override: true }) to flip this.

Stay up to date

Get notified about new guides, tools, and cheatsheets.