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.
# 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:
node -e "console.log(require('dotenv').version)"
Output:
16.4.5
Syntax
Call dotenv.config() once, as early in your entry point as possible, before any module that depends on process.env.
require('dotenv').config([options])
// or, with ESM
import 'dotenv/config'
Output: (none — exits 0 on success)
Essential options
| Option | Meaning |
|---|---|
path | Path (string or string[]) to the .env file(s); default .env in cwd |
encoding | File encoding; default utf8 |
debug | Log parsing details and conflicts to stderr |
override | When true, .env values overwrite existing process.env entries |
processEnv | Target object to write into (default process.env) — pass {} to keep .env out of process.env |
quiet | Suppress 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).
# .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:
// 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);
node index.js
Output:
[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:
// index.mjs — ESM
import 'dotenv/config';
console.log('PORT:', process.env.PORT);
node index.mjs
Output:
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):
| File | Purpose | Commit to git? |
|---|---|---|
.env | Defaults shared across all envs | Yes (no secrets) |
.env.local | Developer-specific overrides | No |
.env.development / .env.production / .env.test | Per-environment defaults | Yes (no secrets) |
.env.development.local / .env.production.local | Per-env, machine-specific overrides | No |
Add the .local variants to .gitignore:
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):
// 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;
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):
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.
node -r dotenv/config index.js
Output:
[dotenv@16.4.5] injecting env (4) from .env
Server listening on port 3000
Pass options via CLI flags prefixed with dotenv_config_:
node -r dotenv/config index.js \
dotenv_config_path=/etc/myapp/.env.production \
dotenv_config_debug=true
Output:
[dotenv][DEBUG] PORT is already defined and was NOT overwritten
[dotenv][DEBUG] DATABASE_URL is set to postgres://...
Or via environment variables (same effect):
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.
# 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:
Server listening on port 3000
Comparison with dotenv:
| Feature | dotenv | node --env-file |
|---|---|---|
| Zero deps | Yes | Yes (built-in) |
| Variable expansion | With dotenv-expand | No |
| Multi-line values | Yes | Yes |
| Precedence rules | Configurable via override | Last-loaded wins |
--env-file-if-exists | n/a | Node 22+ |
Custom processEnv target | Yes | No |
| TypeScript types | @types/node not needed | @types/node |
| Browser/edge runtimes | No | No (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.
# .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
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);
NODE_ENV=production node index.js
Output:
postgres://alicedev:hunter2@db.internal:5432/myapp
/home/alice/logs/production/app.log
Escape $ with a backslash if you need a literal dollar sign:
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).
npm install --save-dev dotenv-cli
Output: (none — exits 0 on success)
Run a command with a specific env file:
# 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:
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.
// 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:
import { env } from './env';
const server = Bun.serve({ port: env.PORT });
console.log(`Listening on :${env.PORT} (${env.NODE_ENV})`);
node --import tsx src/index.ts
Output:
Listening on :3000 (development)
When validation fails:
DATABASE_URL=not-a-url node --import tsx src/index.ts
Output:
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:
- Validate once into a typed object (preferred — pattern above).
- Augment
NodeJS.ProcessEnvfor codebases that readprocess.envdirectly:
// 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 {};
- Use
dotenv-vaultort3-envwhich auto-generate types at build time.
After option 2, autocomplete works:
const port = parseInt(process.env.PORT, 10); // .PORT is now string, no `?`
Quoting, escapes, and multi-line values
Modern dotenv (v16+) parses these forms:
# 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:
require('dotenv').config();
console.log(process.env.GREETING);
console.log(JSON.parse(process.env.FEATURES));
node index.js
Output:
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.
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.
# 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:
[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
- Loading
.envafter a config import —require('./config')runs beforerequire('dotenv').config(), so the config file seesundefinedfor every key. Always calldotenv.config()first, or use-r dotenv/configon the CLI. - Committing
.envto git — even "non-secret" files leak through deploy logs and forks. Add.env*to.gitignoreand commit a sanitized.env.exampleinstead. - Expecting numbers or booleans — every value is a string.
parseInt(process.env.PORT, 10)orprocess.env.FLAG === 'true'. Zod'sz.coerce.number()does this safely. override: falseby default — if you setFOO=barin your shell,dotenv.config()won't overwrite it. Passoverride: truefor the opposite, or design around it.# commentinside a value —KEY=abc # noteis parsed asabc(whitespace +#ends the value);KEY=abc#notekeeps#noteliterally. Quote when unsure.- Mixing
--env-fileanddotenv— both can run, but loading order andoverridesemantics differ. Pick one per project to avoid confusion. - Variable expansion in plain dotenv —
${VAR}is not expanded bydotenvitself; you needdotenv-expand. Otherwise the literal string${VAR}ends up inprocess.env. - Quoting an unquoted dollar sign —
PASSWORD=abc$123withdotenv-expandtries to expand$123. Use single quotes or escape:PASSWORD='abc$123'orPASSWORD=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.
// 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);
// 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}`));
NODE_ENV=development node --import tsx src/server.ts
Output:
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.
# .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
{
"scripts": {
"postinstall": "node scripts/check-env.js"
}
}
// 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);
}
npm install
Output:
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.
# 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
docker compose up
Output:
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.
# .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
gh workflow run test.yml
Output:
Run started.
Reload .env on change (development)
Pair --env-file with Node's --watch flag for an in-process reload loop without nodemon.
node --watch --env-file=.env src/server.ts
Output:
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:
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.
# 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:
Datasource "db": PostgreSQL database "myapp_staging" at "db.staging:5432"
1 seed file completed in 1.2s.