cheat sheet

dotenv

Load configuration and secrets from .env files into process.env at startup, with multi-environment files, variable expansion, schema validation, and Node 20.6+ built-in --env-file alternatives.

dotenv — Environment Variables from .env Files

What it is

dotenv is a zero-dependency Node.js module that loads key/value pairs from a .env file into process.env at runtime, originally inspired by the Twelve-Factor App methodology. It's maintained by Motdotla and ships ~30k weekly downloads on npm with no transitive dependencies. Reach for it whenever you need to keep secrets, API keys, database URLs, and per-environment config out of source control — and use Node 20.6+ built-in --env-file=.env flag or node --env-file-if-exists=.env (24+) as a lighter alternative when you don't need expansion or multi-file precedence.

Install

Install dotenv as a runtime dependency. For monorepo workflows or running scripts with multiple env files, the dotenv-cli package is useful too.

bash
# npm
npm install dotenv

# yarn / pnpm / bun
yarn add dotenv
pnpm add dotenv
bun add dotenv

# Optional helpers
npm install --save-dev dotenv-cli dotenv-expand

Output: (none — exits 0 on success)

Verify version:

bash
node -e "console.log(require('dotenv').version)"

Output:

text
16.4.5

Syntax

Call dotenv.config() once, as early in your entry point as possible, before any module that depends on process.env.

javascript
require('dotenv').config([options])
// or, with ESM
import 'dotenv/config'

Output: (none — exits 0 on success)

Essential options

OptionMeaning
pathPath (string or string[]) to the .env file(s); default .env in cwd
encodingFile encoding; default utf8
debugLog parsing details and conflicts to stderr
overrideWhen true, .env values overwrite existing process.env entries
processEnvTarget object to write into (default process.env) — pass {} to keep .env out of process.env
quietSuppress the "[dotenv@N] injecting env" banner (v16.4+)

Basic .env file

A .env file is plain text with one KEY=VALUE per line. Keys are conventionally SCREAMING_SNAKE_CASE; values are strings (cast inside your code when you need numbers/booleans).

text
# .env — comments start with #
NODE_ENV=development
PORT=3000
HOST=localhost

# Quotes preserve whitespace and special chars
GREETING="Hello, world!"
MULTILINE="line one
line two
line three"

# Empty values are allowed
DEBUG=

# Inline comments after values
SECRET_KEY=abc123 # not part of the value if # has whitespace before it

Load it from your entry point:

javascript
// index.js — CommonJS
require('dotenv').config();
console.log('PORT:', process.env.PORT);
console.log('NODE_ENV:', process.env.NODE_ENV);
console.log('GREETING:', process.env.GREETING);
bash
node index.js

Output:

text
[dotenv@16.4.5] injecting env (4) from .env
PORT: 3000
NODE_ENV: development
GREETING: Hello, world!

The same idea in ESM with the side-effect import:

javascript
// index.mjs — ESM
import 'dotenv/config';
console.log('PORT:', process.env.PORT);
bash
node index.mjs

Output:

text
PORT: 3000

File precedence and multi-environment setup

The official guidance is to use the same .env file across environments and switch credentials at deploy-time via the platform's secret manager. In practice many teams keep separate files per environment; load them in order so the more-specific file wins.

The conventional precedence (highest priority last when you load them in order without override):

FilePurposeCommit to git?
.envDefaults shared across all envsYes (no secrets)
.env.localDeveloper-specific overridesNo
.env.development / .env.production / .env.testPer-environment defaultsYes (no secrets)
.env.development.local / .env.production.localPer-env, machine-specific overridesNo

Add the .local variants to .gitignore:

bash
echo ".env.local" >> .gitignore
echo ".env.*.local" >> .gitignore

Output: (none — exits 0 on success)

Loading layered files (later loads overwrite earlier ones only when override: true):

javascript
// config/env.js
const dotenv = require('dotenv');
const env = process.env.NODE_ENV || 'development';

// Load base first
dotenv.config({ path: '.env' });
// Then env-specific (override existing keys)
dotenv.config({ path: `.env.${env}`, override: true });
// Then local overrides (highest priority)
dotenv.config({ path: `.env.${env}.local`, override: true });
dotenv.config({ path: '.env.local', override: true });

module.exports = process.env;
bash
NODE_ENV=production node -r ./config/env.js index.js

Output: (none — exits 0 on success)

Or load multiple paths in one call (dotenv merges them, first file wins unless override):

javascript
require('dotenv').config({
  path: ['.env.local', '.env'],
});

Output: (none — exits 0 on success)

Preloading without modifying source

Use -r dotenv/config (Node's "require" flag) to load .env before your script starts — handy when you don't want to add require('dotenv').config() to the top of every entry point.

bash
node -r dotenv/config index.js

Output:

text
[dotenv@16.4.5] injecting env (4) from .env
Server listening on port 3000

Pass options via CLI flags prefixed with dotenv_config_:

bash
node -r dotenv/config index.js \
  dotenv_config_path=/etc/myapp/.env.production \
  dotenv_config_debug=true

Output:

text
[dotenv][DEBUG] PORT is already defined and was NOT overwritten
[dotenv][DEBUG] DATABASE_URL is set to postgres://...

Or via environment variables (same effect):

bash
DOTENV_CONFIG_PATH=/etc/myapp/.env.production node -r dotenv/config index.js

Output: (none — exits 0 on success)

Node.js 20.6+ built-in --env-file

Node 20.6+ has a built-in --env-file flag that does most of what dotenv does, with zero dependencies. Use it when you don't need variable expansion, multi-file precedence, or processEnv redirection.

bash
# Single env file
node --env-file=.env index.js

# Multiple files — right-most wins
node --env-file=.env --env-file=.env.local index.js

# Don't fail if the file is missing (Node 22+)
node --env-file-if-exists=.env.production index.js

Output:

text
Server listening on port 3000

Comparison with dotenv:

Featuredotenvnode --env-file
Zero depsYesYes (built-in)
Variable expansionWith dotenv-expandNo
Multi-line valuesYesYes
Precedence rulesConfigurable via overrideLast-loaded wins
--env-file-if-existsn/aNode 22+
Custom processEnv targetYesNo
TypeScript types@types/node not needed@types/node
Browser/edge runtimesNoNo (Node only)

Variable expansion with dotenv-expand

dotenv-expand adds POSIX-style variable substitution inside .env values — useful for composing connection strings without duplicating credentials.

text
# .env
DB_HOST=db.internal
DB_PORT=5432
DB_USER=alicedev
DB_PASS=hunter2
# Reference other keys with $VAR or ${VAR}
DATABASE_URL=postgres://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/myapp
LOG_PATH=/home/alice/logs/${NODE_ENV}/app.log
javascript
const dotenv = require('dotenv');
const dotenvExpand = require('dotenv-expand');

const env = dotenv.config();
dotenvExpand.expand(env);

console.log(process.env.DATABASE_URL);
console.log(process.env.LOG_PATH);
bash
NODE_ENV=production node index.js

Output:

text
postgres://alicedev:hunter2@db.internal:5432/myapp
/home/alice/logs/production/app.log

Escape $ with a backslash if you need a literal dollar sign:

text
PRICE=\$9.99

dotenv-cli — run any command with .env loaded

dotenv-cli is a small wrapper that loads a .env file and then execs any command — useful for running tools that don't import dotenv themselves (Prisma, Drizzle, custom shell scripts).

bash
npm install --save-dev dotenv-cli

Output: (none — exits 0 on success)

Run a command with a specific env file:

bash
# Run prisma migrate with production credentials
npx dotenv -e .env.production -- npx prisma migrate deploy

# Run a script with multiple env files (later overrides earlier)
npx dotenv -e .env -e .env.local -- node index.js

# In package.json scripts
{
  "scripts": {
    "migrate:prod": "dotenv -e .env.production -- prisma migrate deploy",
    "seed:dev":     "dotenv -e .env.development -- tsx prisma/seed.ts"
  }
}

Output:

text
Datasource "db": PostgreSQL database "myapp", schema "public" at "db.internal:5432"
1 migration applied.

The -- separator tells dotenv-cli where its own flags end and the wrapped command begins.

Validation with Zod (or any schema library)

Loading .env is half the job — the other half is asserting that required variables exist and have the right shape before the app starts. The classic pattern is to validate process.env against a Zod schema and re-export a typed env object.

typescript
// src/env.ts
import 'dotenv/config';
import { z } from 'zod';

const schema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().min(1),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  ENABLE_TELEMETRY: z
    .enum(['true', 'false'])
    .transform((v) => v === 'true')
    .default('false'),
});

const parsed = schema.safeParse(process.env);
if (!parsed.success) {
  console.error('Invalid environment variables:');
  console.error(parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;
export type Env = typeof env;

Use it anywhere in the app — env.PORT is number, env.ENABLE_TELEMETRY is boolean, autocompleted by TypeScript:

typescript
import { env } from './env';

const server = Bun.serve({ port: env.PORT });
console.log(`Listening on :${env.PORT} (${env.NODE_ENV})`);
bash
node --import tsx src/index.ts

Output:

text
Listening on :3000 (development)

When validation fails:

bash
DATABASE_URL=not-a-url node --import tsx src/index.ts

Output:

text
Invalid environment variables:
{
  DATABASE_URL: [ 'Invalid url' ],
  STRIPE_SECRET_KEY: [ 'Required' ]
}

For zero-config typed env in a monorepo, the @t3-oss/env-core package wraps this pattern with separate client/server splits — useful in Next.js or Remix apps.

TypeScript: typing process.env

By default process.env.FOO is string | undefined. Three ways to fix that:

  1. Validate once into a typed object (preferred — pattern above).
  2. Augment NodeJS.ProcessEnv for codebases that read process.env directly:
typescript
// src/types/env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    NODE_ENV: 'development' | 'test' | 'production';
    PORT: string;             // process.env is always strings
    DATABASE_URL: string;
    STRIPE_SECRET_KEY: string;
  }
}
export {};
  1. Use dotenv-vault or t3-env which auto-generate types at build time.

After option 2, autocomplete works:

typescript
const port = parseInt(process.env.PORT, 10); // .PORT is now string, no `?`

Quoting, escapes, and multi-line values

Modern dotenv (v16+) parses these forms:

text
# Unquoted — trailing whitespace trimmed, value is treated as plain string
SIMPLE=hello world

# Single quotes — no expansion, literal value
LITERAL='${NOT_EXPANDED}'

# Double quotes — preserves whitespace, supports \n \r \t escapes
GREETING="Hello,\n  world!"

# Backticks — preserves everything verbatim (no escape processing)
RAW=`{"json":"value with \n literal backslash-n"}`

# Multi-line via double quotes
PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQ...
-----END PRIVATE KEY-----"

# JSON values are valid strings — parse them in code
FEATURES={"auth":true,"billing":false}

In code:

javascript
require('dotenv').config();
console.log(process.env.GREETING);
console.log(JSON.parse(process.env.FEATURES));
bash
node index.js

Output:

text
Hello,
  world!
{ auth: true, billing: false }

Keeping .env out of process.env

Passing processEnv: {} writes parsed values into a fresh object instead of mutating process.env. Useful when you want to keep secrets out of child processes that inherit the parent's env.

javascript
const dotenv = require('dotenv');

const myEnv = {};
dotenv.config({ path: '.env.secrets', processEnv: myEnv });

// process.env is untouched
console.log(process.env.STRIPE_SECRET_KEY); // undefined
console.log(myEnv.STRIPE_SECRET_KEY);       // 'sk_live_...'

// Spawn a child with a curated env
const { spawn } = require('node:child_process');
spawn('worker.js', [], {
  env: { ...process.env, PORT: myEnv.PORT } // only PORT gets passed
});

Output: (none — exits 0 on success)

Encrypted .env with dotenvx

dotenvx is a successor project from the same author that adds public-key encryption for .env.vault files, letting you commit encrypted secrets safely.

bash
# Install
npm install --save-dev @dotenvx/dotenvx

# Encrypt a .env into .env.vault using a public key
npx dotenvx encrypt -f .env.production

# Run with a decryption key (DOTENV_PRIVATE_KEY)
DOTENV_PRIVATE_KEY=ed25519... npx dotenvx run -- node index.js

Output:

text
[dotenvx@1.x] injecting env (12) from .env.production
Server listening on port 3000

Use dotenvx when you want a single committed file (.env.vault) and per-environment private keys instead of separate .env.*.local files in a secrets manager.

Common pitfalls

  1. Loading .env after a config importrequire('./config') runs before require('dotenv').config(), so the config file sees undefined for every key. Always call dotenv.config() first, or use -r dotenv/config on the CLI.
  2. Committing .env to git — even "non-secret" files leak through deploy logs and forks. Add .env* to .gitignore and commit a sanitized .env.example instead.
  3. Expecting numbers or booleans — every value is a string. parseInt(process.env.PORT, 10) or process.env.FLAG === 'true'. Zod's z.coerce.number() does this safely.
  4. override: false by default — if you set FOO=bar in your shell, dotenv.config() won't overwrite it. Pass override: true for the opposite, or design around it.
  5. # comment inside a valueKEY=abc # note is parsed as abc (whitespace + # ends the value); KEY=abc#note keeps #note literally. Quote when unsure.
  6. Mixing --env-file and dotenv — both can run, but loading order and override semantics differ. Pick one per project to avoid confusion.
  7. Variable expansion in plain dotenv${VAR} is not expanded by dotenv itself; you need dotenv-expand. Otherwise the literal string ${VAR} ends up in process.env.
  8. Quoting an unquoted dollar signPASSWORD=abc$123 with dotenv-expand tries to expand $123. Use single quotes or escape: PASSWORD='abc$123' or PASSWORD=abc\$123.

Real-world recipes

Multi-environment Express server with typed env

A common pattern: a single env.ts that loads, validates, and exports a typed env object the rest of the app imports.

typescript
// src/env.ts
import { config } from 'dotenv';
import { expand } from 'dotenv-expand';
import { z } from 'zod';

const file = `.env.${process.env.NODE_ENV ?? 'development'}`;
expand(config({ path: ['.env', file, '.env.local'] }));

export const env = z
  .object({
    NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
    PORT: z.coerce.number().int().default(3000),
    DATABASE_URL: z.string().url(),
    JWT_SECRET: z.string().min(32),
    REDIS_URL: z.string().url().optional(),
  })
  .parse(process.env);
typescript
// src/server.ts
import express from 'express';
import { env } from './env';

const app = express();
app.get('/health', (_, res) => res.json({ env: env.NODE_ENV }));
app.listen(env.PORT, () => console.log(`http://localhost:${env.PORT}`));
bash
NODE_ENV=development node --import tsx src/server.ts

Output:

text
http://localhost:3000

.env.example workflow for new contributors

Commit a placeholder file alongside the project so new clones know which variables to supply. Combine with npm-run-all to check .env exists on postinstall.

text
# .env.example — checked into git, no real secrets
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://alicedev:password@localhost:5432/myapp_dev
JWT_SECRET=change-me-min-32-characters-long-please
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
json
{
  "scripts": {
    "postinstall": "node scripts/check-env.js"
  }
}
javascript
// scripts/check-env.js
const { existsSync } = require('node:fs');

if (!existsSync('.env')) {
  console.error('\n  Missing .env — copy .env.example to .env and fill in real values.\n');
  process.exit(1);
}
bash
npm install

Output:

text
  Missing .env — copy .env.example to .env and fill in real values.

Docker Compose: load .env for both Compose and the app

docker compose reads .env from the directory containing compose.yaml for variable substitution. Re-use the same file inside the container by mounting it or using env_file.

yaml
# compose.yaml
services:
  api:
    image: node:22-alpine
    working_dir: /app
    volumes:
      - .:/app
    env_file:
      - .env
      - .env.production
    ports:
      - "${PORT}:${PORT}"
    command: node --env-file=.env index.js
bash
docker compose up

Output:

text
api-1  | Server listening on port 3000

CI: inject secrets without writing .env to disk

In GitHub Actions, set env vars directly via the env: block — no .env file needed. process.env is populated from the runner.

yaml
# .github/workflows/test.yml
jobs:
  test:
    runs-on: ubuntu-latest
    env:
      DATABASE_URL: ${{ secrets.DATABASE_URL }}
      NODE_ENV: test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npm ci
      - run: npm test
bash
gh workflow run test.yml

Output:

text
Run started.

Reload .env on change (development)

Pair --env-file with Node's --watch flag for an in-process reload loop without nodemon.

bash
node --watch --env-file=.env src/server.ts

Output:

text
Completed running 'src/server.ts'
Restarting 'src/server.ts'
Server listening on port 3000

Changes to .env itself don't trigger a reload — only to watched source files. If you need that too, add --watch-path=.env:

bash
node --watch --watch-path=. --env-file=.env src/server.ts

Output: (none — exits 0 on success)

Prisma + dotenv-cli for migrations

Prisma reads DATABASE_URL from .env automatically, but for non-default file names use dotenv-cli so Prisma picks up the right one.

bash
# Apply pending migrations against the production DB
npx dotenv -e .env.production -- npx prisma migrate deploy

# Run the seed script against staging
npx dotenv -e .env.staging -- npx tsx prisma/seed.ts

Output:

text
Datasource "db": PostgreSQL database "myapp_staging" at "db.staging:5432"
1 seed file completed in 1.2s.