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

bash
npm install dotenv

Output: added dotenv to dependencies

bash
pnpm add dotenv

Output: added 1 package, linked from store

bash
yarn add dotenv

Output: added dotenv

bash
bun add dotenv

Output: installed dotenv — note Bun also loads .env natively without dotenv

bash
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 → 16 tightened 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 .env files. 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. Avoids node -r dotenv/config.
  • @dotenvx/dotenvx — modern successor by the same maintainer. CLI-only, supports encrypted .env files, multi-env merging, and replaces both dotenv and dotenv-cli. Recommended for new projects.
  • dotenv-flow — opinionated multi-environment loader (.env, .env.local, .env.development, etc.) inspired by Ruby on Rails.

Alternatives

PackageTrade-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.
dotenvxSuccessor — supports encryption, multi-env, expansion. CLI-first.
dotenv-flowMulti-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

  1. Not loaded before module-graph evaluation. import config from "./config"; at the top of a file runs BEFORE dotenv.config() if the import precedes the call. Solutions: preload via node -r dotenv/config app.js, run via dotenv-cli, or put dotenv.config() in a separate file that you import first.
  2. .env parsing is NOT POSIX shell. dotenv handles quoting and newlines, but does not expand $VAR references by default (use dotenv-expand) and treats # as comment ONLY when preceded by whitespace or at line-start. Values with # inside need quoting.
  3. 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…".
  4. override is 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.
  5. Quoting differs subtly from .env.example parsers 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.
  6. Bun and Deno load .env natively. Adding the dotenv package in those runtimes is redundant unless you also need the explicit dotenv.config() programmatic call. Bun loads .env, .env.local, .env.<NODE_ENV> automatically; Deno requires --env-file.
  7. Never commit .env. Always commit .env.example with 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.

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

text
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.

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

bash
npm install -g @dotenvx/dotenvx

Output: dotenvx on PATH

bash
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).

bash
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:

text
#/-----------------------/
#/ 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.

typescript
import dotenv from "dotenv";
import dotenvExpand from "dotenv-expand";

dotenvExpand.expand(dotenv.config());
text
# .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).

bash
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.

PlatformMechanism
VercelProject Settings → Environment Variables. Injected automatically; no .env needed.
Cloudflare Workerswrangler secret put NAME (per env) or [vars] in wrangler.toml for non-secret config.
Fly.ioflyctl secrets set NAME=val. Encrypted at rest.
AWS LambdaFunction env vars in the console / IaC, or fetch from Secrets Manager at cold start.
KubernetesSecret resources mounted as files or env. Avoid configMap for secrets.
systemdEnvironmentFile=/etc/myapp/env in the unit file. The file should be chmod 600 root:root.
PM2 / Docker Composeenv_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:

yaml
# .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:

yaml
- 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 .gitignore before the first commit. Use .gitignore, .git/info/exclude, AND a global ~/.gitignore_global to make double-commits impossible. Inspect with git check-ignore -v .env.
  • Pre-commit hook to block secrets. Tools like gitleaks, trufflehog, and git-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 .env file you committed. Removing a file from history (git filter-repo) does NOT invalidate already-leaked credentials. Rotate first, scrub history second.
  • .env.example should have placeholder values. Never copy a real value "as documentation". The convention is API_KEY=REPLACE_ME or API_KEY=.
  • dotenv silently logs the path on success in older versions. Set silent: true in production or upgrade — log lines like "Loaded .env from /app/.env" reveal disk layout.
  • override: true overrides platform secrets. In production, the platform already injects DATABASE_URL. A .env file with override: true will replace the platform value — leaving you on a stale or test DB.
  • dotenvx-encrypted secrets still need a key. The encrypted .env.production is safe to commit; the key in .env.keys (or DOTENV_PRIVATE_KEY_* in CI) is not. Treat the key like a master password.
  • Container-image leakage. A multi-stage Dockerfile that does COPY . . copies .env into the image. Add .env* to .dockerignore AND verify with docker run --rm img cat .env after a build.
  • Process listings expose env. ps -e ww shows the command line; /proc/<pid>/environ shows 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=false is 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.

FromToNotes
dotenv@15dotenv@16Stricter 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/dotenvxCLI-first model with encryption. Migration: keep .env for local dev, move production secrets into encrypted .env.production, use dotenvx run -- <cmd> everywhere.
dotenv + dotenv-expanddotenvxdotenvx expands by default. Drop dotenv-expand once on dotenvx.
Custom env loaderdotenvReplace fs.readFileSync + manual parsing with dotenv.config({ path }).
dotenvVite / Next.js / Nest configThese frameworks load .env themselves. Remove direct dotenv usage to avoid double-load races.

Node --env-file flag (20.6+)

bash
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.

SetupPattern
CJS apprequire("dotenv").config(); at the top of index.js.
CJS app (preload)node -r dotenv/config app.js. Loads dotenv before any user code.
ESM appimport "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.
TypeScriptimport "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.jsSame — Next loads .env, .env.local, .env.<NODE_ENV>. Adding dotenv duplicates the work.
Cloudflare Workersdotenv doesn't run on Workers (no filesystem). Use wrangler secret put for prod and .dev.vars for local.
Edge runtimes generallySame — no filesystem at runtime. Use platform secrets or build-time env baking.
DenoUse --env-file=.env (Deno 2+) or import { load } from "@std/dotenv". The npm dotenv works but is redundant.
BunBuilt-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

PackageRole
dotenv-expand${VAR} interpolation inside .env. Required for cross-referencing vars.
dotenv-cliWrap any command with a preloaded .env. dotenv -e .env.prod -- node app.js.
@dotenvx/dotenvxEncrypted-vault successor. CLI-first, supports per-env encryption, multi-env merging.
dotenv-flowOpinionated Rails-style multi-env loader. Heavier convention than ad-hoc layering.
@t3-oss/env-core, @t3-oss/env-nextjsSchema-validated env vars with Zod. Type-safe at compile time.
envalidValidation library specifically for env vars. Predates @t3-oss/env.
dotenv-vault (deprecated)Earlier vault model. Use @dotenvx/dotenvx instead.
cross-envCross-platform env var setting in npm scripts (cross-env NODE_ENV=production node app). Common companion to dotenv.
env-cmdOlder alternative to dotenv-cli. Largely superseded.
find-configLocate .env-style files walking up the directory tree. Useful for monorepo tooling.
gitleaks, trufflehog, git-secretsScanners for secrets in commits. Wire into pre-commit.
sops, git-crypt, ageWhole-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

typescript
// vitest.setup.ts
import dotenv from "dotenv";
dotenv.config({ path: ".env.test", override: true });
typescript
// 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

typescript
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

yaml
# .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 .envdotenv.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 outputdotenv-expand not installed. npm install dotenv-expand and wrap: dotenvExpand.expand(dotenv.config()).

Platform-injected vars get overwrittenoverride: 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.js covers 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