cheat sheet
dotenv
Package-level reference for the dotenv config loader on npm — install, native Node alternatives, dotenvx successor, and gotchas.
dotenv
What it is
dotenv is a Node.js library that loads key/value pairs from a .env file into process.env at runtime. It is the original implementation of the .env-file pattern in the Node ecosystem (the Ruby dotenv gem predates it; both inspired the Python python-dotenv).
Reach for dotenv when you need a battle-tested, zero-dep loader compatible with every Node version. Reach for dotenvx for variable expansion + encrypted-.env support, or for Node's native --env-file flag (Node 20.6+) when you don't need any expansion logic.
Install
npm install dotenv
Output: added dotenv to dependencies
pnpm add dotenv
Output: added 1 package, linked from store
yarn add dotenv
Output: added dotenv
bun add dotenv
Output: installed dotenv — note Bun also loads .env natively without dotenv
deno add npm:dotenv
Output: added npm:dotenv to import map
Versioning & Node support
Current line is dotenv@16.x. The package is intentionally tiny and conservative — feature growth happens in sibling packages (dotenv-expand, @dotenvx/dotenvx).
dotenv@16— Node 12+. Dual ESM/CJS.- Strict semver. Major bumps happen rarely and usually rewrite the parser.
15 → 16tightened newline handling on Windows. - TypeScript types ship in-tree.
Package metadata
- Maintainer: Motdotla / Mot (Scott Motte) and contributors
- Project home: github.com/motdotla/dotenv
- Docs: dotenvx.com (covers both the classic loader and the newer dotenvx CLI)
- npm: npmjs.com/package/dotenv
- License: BSD-2-Clause
- First released: 2013
- Downloads: ~50+ million weekly downloads — one of the top-10 most-downloaded npm packages.
Peer dependencies & extras
dotenv itself has zero runtime dependencies. No peer-deps, no extras.
Adjacent packages in the same family:
dotenv-expand— adds${VAR}interpolation inside.envfiles. Required if you reference one var from another (DATABASE_URL=postgres://${DB_USER}@${DB_HOST}).dotenv-cli— wraps any command with a pre-loaded.env:dotenv -e .env.prod -- node server.js. Avoidsnode -r dotenv/config.@dotenvx/dotenvx— modern successor by the same maintainer. CLI-only, supports encrypted.envfiles, multi-env merging, and replaces bothdotenvanddotenv-cli. Recommended for new projects.dotenv-flow— opinionated multi-environment loader (.env,.env.local,.env.development, etc.) inspired by Ruby on Rails.
Alternatives
| Package | Trade-off |
|---|---|
Node --env-file=.env flag (20.6+) | Built-in. No dependency. Doesn't support expansion, comments are limited, and the flag is per-invocation. Best for simple cases. |
Node process.loadEnvFile() (22+) | Programmatic equivalent of --env-file. Same limitations. |
dotenvx | Successor — supports encryption, multi-env, expansion. CLI-first. |
dotenv-flow | Multi-env layered loading. Heavier convention. |
| Framework loaders (Next.js, Vite, NestJS) | Each ships its own .env loader on top of dotenv. Don't add dotenv on top — the framework already handles it. |
| Secret managers (AWS Secrets Manager, Doppler, Infisical) | Pull secrets at runtime from a managed service. The right answer at scale; overkill for solo projects. |
Common gotchas
- Not loaded before module-graph evaluation.
import config from "./config";at the top of a file runs BEFOREdotenv.config()if the import precedes the call. Solutions: preload vianode -r dotenv/config app.js, run viadotenv-cli, or putdotenv.config()in a separate file that you import first. .envparsing is NOT POSIX shell. dotenv handles quoting and newlines, but does not expand$VARreferences by default (usedotenv-expand) and treats#as comment ONLY when preceded by whitespace or at line-start. Values with#inside need quoting.- Multi-line values need quoted strings. Embed real newlines inside double-quotes, or escape with
\n. RSA keys are a classic painpoint:PRIVATE_KEY="-----BEGIN…\n…\n-----END…". overrideis opt-in. By default, dotenv does NOT overwrite already-set environment variables — useful in production where the platform injects them. Pass{ override: true }to flip this for local dev.- Quoting differs subtly from
.env.exampleparsers in editors. VS Code's dotenv extension and editorconfig don't always match the same parse rules — your editor may highlight valid syntax as broken. - Bun and Deno load
.envnatively. Adding the dotenv package in those runtimes is redundant unless you also need the explicitdotenv.config()programmatic call. Bun loads.env,.env.local,.env.<NODE_ENV>automatically; Deno requires--env-file. - Never commit
.env. Always commit.env.examplewith placeholder values and add.env*(or.env.local) to.gitignore. dotenvx solves this with built-in encryption so you can commit encrypted.env.production.
Real-world recipes
These recipes focus on layered loading, encrypted vaults, and CLI integration — the operational concerns around .env files that the API docs don't cover.
Layered envs by NODE_ENV
The Rails / Next.js convention: load .env, then .env.local, then .env.<NODE_ENV>, then .env.<NODE_ENV>.local. Later files override earlier values.
import dotenv from "dotenv";
import { existsSync } from "node:fs";
const env = process.env.NODE_ENV ?? "development";
const files = [
".env",
".env.local",
`.env.${env}`,
`.env.${env}.local`,
];
for (const path of files) {
if (existsSync(path)) {
dotenv.config({ path, override: true });
console.log(`loaded ${path}`);
}
}
Output:
loaded .env
loaded .env.local
loaded .env.production
The override: true flag is essential — without it, later files silently lose to earlier ones. The Next.js / Vite loaders implement this same ordering internally.
Validate at startup with Zod
dotenv loads strings; production apps should fail fast on missing or malformed config. Zod (or @t3-oss/env-core) gives you a typed contract.
import dotenv from "dotenv";
import { z } from "zod";
dotenv.config();
const Env = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().int().positive().default(3000),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
export const env = Env.parse(process.env);
Output: running with a missing DATABASE_URL exits with a clear schema error pointing at the offending key, rather than failing deep inside a query.
dotenvx encrypted-vault model
@dotenvx/dotenvx is the modern successor. It encrypts .env.production with a public key derived from a private key kept in .env.keys (gitignored). Anyone with the key can decrypt; the encrypted file is safe to commit.
npm install -g @dotenvx/dotenvx
Output: dotenvx on PATH
dotenvx set DATABASE_URL postgres://alice@localhost/app -f .env.production
Output: .env.production now contains an encrypted ciphertext for DATABASE_URL, and .env.keys holds the matching private key (auto-added to .gitignore).
dotenvx run -f .env.production -- node server.js
Output: runs server.js with DATABASE_URL decrypted only in memory.
The committed .env.production looks like:
#/-----------------------/
#/ DOTENV_PUBLIC_KEY_PRODUCTION=02ab… /
#/-----------------------/
DOTENV_PUBLIC_KEY_PRODUCTION="02ab..."
DATABASE_URL="encrypted:BCYx...=="
Decryption requires DOTENV_PRIVATE_KEY_PRODUCTION in env (set in CI as a secret) or the .env.keys file (local dev only).
Variable expansion with dotenv-expand
dotenv core does not expand ${VAR} references — DATABASE_URL=postgres://${DB_USER}@${DB_HOST} becomes a literal string. dotenv-expand adds the expansion pass.
import dotenv from "dotenv";
import dotenvExpand from "dotenv-expand";
dotenvExpand.expand(dotenv.config());
# .env
DB_USER=alice
DB_HOST=db.example.com
DATABASE_URL=postgres://${DB_USER}@${DB_HOST}:5432/app
Output: process.env.DATABASE_URL is postgres://alice@db.example.com:5432/app.
dotenv-expand honours the same syntax as bash parameter expansion: ${VAR}, ${VAR:-default}, ${VAR:?required}.
One-off runs via dotenv-cli
Spawning a process with a specific .env without writing a loader is the use case for dotenv-cli (or dotenvx run).
npx dotenv -e .env.staging -- npm run db:migrate
Output: npm run db:migrate sees .env.staging values; the parent shell is unaffected.
This is the canonical pattern for "run a script against a different env": database migrations, debug sessions, ad-hoc imports.
Production deployment
In production you should NOT use a .env file. The platform's secret management (Vercel env, Fly secrets, Cloudflare Workers secrets, AWS Secrets Manager, K8s secrets, systemd EnvironmentFile) injects vars before the process starts.
| Platform | Mechanism |
|---|---|
| Vercel | Project Settings → Environment Variables. Injected automatically; no .env needed. |
| Cloudflare Workers | wrangler secret put NAME (per env) or [vars] in wrangler.toml for non-secret config. |
| Fly.io | flyctl secrets set NAME=val. Encrypted at rest. |
| AWS Lambda | Function env vars in the console / IaC, or fetch from Secrets Manager at cold start. |
| Kubernetes | Secret resources mounted as files or env. Avoid configMap for secrets. |
| systemd | EnvironmentFile=/etc/myapp/env in the unit file. The file should be chmod 600 root:root. |
| PM2 / Docker Compose | env_file: .env.production (compose) or pm2 start --update-env. Both still trust the file's secrecy. |
| Bare metal | /etc/profile.d/myapp.sh or systemd. Avoid global ~/.profile — visible to other users. |
The dotenv usage in production code reduces to a defensive dotenv.config({ silent: true }) that no-ops when the file is absent — useful for local dev only.
CI secret injection
GitHub Actions, GitLab CI, and CircleCI all support secret env vars. The pattern:
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run deploy
If you must use a .env file for tooling (e.g. a build step that reads it via dotenv-cli), generate it from secrets and delete it after the step:
- name: build with .env
run: |
echo "DATABASE_URL=$DATABASE_URL" > .env.production
npm run build
rm .env.production
Security considerations
The single biggest dotenv risk is leaking secrets to git. Every other risk is downstream of that.
- Add
.env*to.gitignorebefore the first commit. Use.gitignore,.git/info/exclude, AND a global~/.gitignore_globalto make double-commits impossible. Inspect withgit check-ignore -v .env. - Pre-commit hook to block secrets. Tools like
gitleaks,trufflehog, andgit-secrets(from AWS Labs) scan staged changes for high-entropy strings and known secret patterns. Wire into.husky/pre-commit. - Rotate any secret that touched a
.envfile you committed. Removing a file from history (git filter-repo) does NOT invalidate already-leaked credentials. Rotate first, scrub history second. .env.exampleshould have placeholder values. Never copy a real value "as documentation". The convention isAPI_KEY=REPLACE_MEorAPI_KEY=.- dotenv silently logs the path on success in older versions. Set
silent: truein production or upgrade — log lines like "Loaded .env from /app/.env" reveal disk layout. override: trueoverrides platform secrets. In production, the platform already injectsDATABASE_URL. A.envfile withoverride: truewill replace the platform value — leaving you on a stale or test DB.- dotenvx-encrypted secrets still need a key. The encrypted
.env.productionis safe to commit; the key in.env.keys(orDOTENV_PRIVATE_KEY_*in CI) is not. Treat the key like a master password. - Container-image leakage. A multi-stage Dockerfile that does
COPY . .copies.envinto the image. Add.env*to.dockerignoreAND verify withdocker run --rm img cat .envafter a build. - Process listings expose env.
ps -e wwshows the command line;/proc/<pid>/environshows env vars (readable by the owner only on Linux). Don't pass secrets via command-line args — env or stdin is safer. - dotenv loads everything as strings. A boolean
DEBUG=falseis the string"false", which is truthy in JS. Always parse / validate (see Zod recipe).
Version migration guide
dotenv's API has been remarkably stable. The migration paths that matter are between dotenv and its alternatives.
| From | To | Notes |
|---|---|---|
dotenv@15 | dotenv@16 | Stricter newline handling on Windows. Multi-line values with \r\n may need re-quoting. |
dotenv (any) | Node --env-file (20.6+) | Built-in, but no expansion, no override semantics, single file. Best for simple cases. Migration: replace node -r dotenv/config app.js with node --env-file=.env app.js. |
dotenv (any) | @dotenvx/dotenvx | CLI-first model with encryption. Migration: keep .env for local dev, move production secrets into encrypted .env.production, use dotenvx run -- <cmd> everywhere. |
dotenv + dotenv-expand | dotenvx | dotenvx expands by default. Drop dotenv-expand once on dotenvx. |
| Custom env loader | dotenv | Replace fs.readFileSync + manual parsing with dotenv.config({ path }). |
dotenv | Vite / Next.js / Nest config | These frameworks load .env themselves. Remove direct dotenv usage to avoid double-load races. |
Node --env-file flag (20.6+)
node --env-file=.env app.js
node --env-file=.env.production --env-file=.env.production.local app.js
Output: (none — exits 0 on success)
What it does: parses the file with the same rules as dotenv and injects into process.env. What it does NOT: variable expansion, override semantics, custom paths via env, dynamic config. For most local-dev cases it's sufficient and zero-dep; for anything more, dotenv (or dotenvx) is still the right answer.
process.loadEnvFile(path) (Node 22+) is the programmatic equivalent.
ESM/CJS interop & bundling
dotenv works in both module systems with no friction. The patterns differ in where you call it.
| Setup | Pattern |
|---|---|
| CJS app | require("dotenv").config(); at the top of index.js. |
| CJS app (preload) | node -r dotenv/config app.js. Loads dotenv before any user code. |
| ESM app | import "dotenv/config"; at the very top. Side-effect import. |
| ESM app (preload) | node --import=dotenv/config app.js (Node 20.6+). Equivalent to -r for ESM. |
| TypeScript | import "dotenv/config"; works under both "module": "commonjs" and "esnext". |
| Vite (server-side) | Vite loads .env itself with prefix conventions (VITE_*). Don't add dotenv on top — it races. |
| Next.js | Same — Next loads .env, .env.local, .env.<NODE_ENV>. Adding dotenv duplicates the work. |
| Cloudflare Workers | dotenv doesn't run on Workers (no filesystem). Use wrangler secret put for prod and .dev.vars for local. |
| Edge runtimes generally | Same — no filesystem at runtime. Use platform secrets or build-time env baking. |
| Deno | Use --env-file=.env (Deno 2+) or import { load } from "@std/dotenv". The npm dotenv works but is redundant. |
| Bun | Built-in .env loading; npm dotenv works but unnecessary. |
The biggest interop mistake is loading dotenv twice — once via a framework and once via your own code. The result is non-deterministic: which file's override: true wins depends on import order.
Plugin & ecosystem coverage
| Package | Role |
|---|---|
dotenv-expand | ${VAR} interpolation inside .env. Required for cross-referencing vars. |
dotenv-cli | Wrap any command with a preloaded .env. dotenv -e .env.prod -- node app.js. |
@dotenvx/dotenvx | Encrypted-vault successor. CLI-first, supports per-env encryption, multi-env merging. |
dotenv-flow | Opinionated Rails-style multi-env loader. Heavier convention than ad-hoc layering. |
@t3-oss/env-core, @t3-oss/env-nextjs | Schema-validated env vars with Zod. Type-safe at compile time. |
envalid | Validation library specifically for env vars. Predates @t3-oss/env. |
dotenv-vault (deprecated) | Earlier vault model. Use @dotenvx/dotenvx instead. |
cross-env | Cross-platform env var setting in npm scripts (cross-env NODE_ENV=production node app). Common companion to dotenv. |
env-cmd | Older alternative to dotenv-cli. Largely superseded. |
find-config | Locate .env-style files walking up the directory tree. Useful for monorepo tooling. |
gitleaks, trufflehog, git-secrets | Scanners for secrets in commits. Wire into pre-commit. |
sops, git-crypt, age | Whole-file encryption tools — alternatives to dotenvx's per-var model. |
Testing & CI integration
Tests should use a dedicated env, not the local .env. The patterns below isolate test runs from your dev secrets.
Load .env.test in test setup
// vitest.setup.ts
import dotenv from "dotenv";
dotenv.config({ path: ".env.test", override: true });
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
setupFiles: ["./vitest.setup.ts"],
},
});
Commit .env.test with non-secret defaults (test DB URL, fixture API keys) — it's not a real-env file, just a test fixture.
Mock env per-test
import { describe, it, expect, beforeEach } from "vitest";
describe("config loader", () => {
beforeEach(() => {
process.env = { ...process.env, NODE_ENV: "test" };
});
it("reads DATABASE_URL", () => {
process.env.DATABASE_URL = "postgres://test";
const { env } = require("../src/config");
expect(env.DATABASE_URL).toBe("postgres://test");
});
});
Vitest auto-resets process.env between modules if isolate: true (default), but reassigning is safer.
Secret-scanning in CI
# .github/workflows/scan.yml
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Troubleshooting common errors
Env var is undefined despite being in .env — dotenv.config() was called too late (after the module that reads process.env). Move the call to the top, or preload via --import=dotenv/config.
Error: ENOENT: no such file or directory, open '.env' — running from a different cwd. Pass explicit path: dotenv.config({ path: "/abs/path/.env" }) or detect with find-config.
Value contains # and parses as a comment — quote the value: URL="https://example.com#section".
Multi-line PRIVATE_KEY breaks — wrap in double quotes and use \n for line breaks, or paste literal newlines inside the quotes (dotenv handles both).
${VAR} appears literally in output — dotenv-expand not installed. npm install dotenv-expand and wrap: dotenvExpand.expand(dotenv.config()).
Platform-injected vars get overwritten — override: true was set. Drop it in production; only use it for layered local dev.
Different behaviour between dotenv and --env-file — they parse comments and newlines slightly differently. The Node native parser is stricter on Windows line endings. Pick one and stick to it.
.env ignored by git status — verify with git check-ignore -v .env. If the file shows as ignored, it's correctly gitignored.
.env showed up in git log -- .env — it was committed at some point. Rotate every secret, then scrub history with git filter-repo --path .env --invert-paths.
When NOT to use this
Skip dotenv when:
- Running on an edge runtime (Cloudflare Workers, Vercel Edge, Deno Deploy). There is no filesystem to read from. Use platform secrets.
- In a framework that already loads
.env(Next.js, Nuxt, Vite, NestJS, SvelteKit, Astro). Adding dotenv on top duplicates the load and creates ordering bugs. - You only need 1-2 vars and Node 20.6+ is available.
node --env-file=.env app.jscovers the simple case with zero deps. - You have a real secrets manager (AWS Secrets Manager, Doppler, Infisical, Vault). Fetch at startup via the SDK. dotenv-style file loading is a stepping stone, not a destination.
- Bun is the runtime. Bun loads
.env,.env.local,.env.<NODE_ENV>automatically with no import. - The values must be different per-request, not per-process (multi-tenant SaaS). Env vars are process-scoped — use a config service that returns per-request context.
See also
- JavaScript: dotenv — runtime API, native --env-file flag, validation patterns
- JavaScript: package.json — where the npm scripts live that load env
- Concept: filesystem — config-file conventions across runtimes