dotenv
dotenv reads a .env file from your project root and copies its key=value pairs into process.env, following the twelve-factor rule of keeping config out of code. It is a zero-dependency module, about 2.6 KB gzipped, and one of the most downloaded packages on npm. You call config() once at startup (or preload with dotenv/config) and every process.env read after that sees your local values. Since v16-17 it also ships a small dotenv run CLI and optional dotenvx integration for encrypted .env files.
Still the simplest, safest default for loading local .env files, but check first whether your Node version or framework already does this natively. If it does, dotenv is a dependency you do not need.
Use it if
- You run Node below 20.6 (or Bun/Deno scripts via npm compat) and need .env loading, since older Node has no built-in --env-file flag
- You want the ecosystem-standard convention: nearly every Node tutorial, Docker guide, and teammate already understands a .env file loaded by dotenv
- You need programmatic control: custom file paths, multiple files with precedence, override behavior, or dotenv.parse() to read env-format strings from anywhere
- You want zero dependencies in a package that sits in nearly every install tree anyway
- You are on Node 20.6+ and only need the basics: node --env-file=.env does the same job with no dependency at all
- Your framework already loads .env: Next.js, Vite, Create React App, and Remix have their own built-in .env handling, and adding dotenv on top causes confusing precedence bugs
- You need variable expansion, encryption, or multi-environment management out of the box: plain dotenv deliberately does none of that and the README repeatedly redirects you to dotenvx, a separate commercialized project
- You are managing production secrets: a plaintext .env file on a server is not a secrets strategy; use your platform's secret manager and keep dotenv for local dev only
Setup reality
npm install dotenv, create .env, call require('dotenv').config() before anything reads process.env. Real friction: since v17 it prints an 'injected env' tip line to stdout by default, which breaks scripts that parse output until you pass quiet: true or set DOTENV_CONFIG_QUIET; in ESM, import hoisting runs your other modules before config() executes, so you need import 'dotenv/config' instead of calling config() yourself; existing env vars are never overwritten unless you pass override: true, which confuses everyone once; and the README is now heavily an advertisement for dotenvx, so finding plain dotenv docs takes scrolling.
Patterns
Load .env into process.env (CommonJS)load-env
// index.js, first line, before anything reads process.env
require('dotenv').config()
console.log(process.env.HELLO)Anything that reads process.env before config() runs sees undefined; put it at the very top of the entry file.
Load .env in ESM without hoisting bugsesm-import
// ESM: import statements are hoisted, so use the side-effect import
import 'dotenv/config'
import { startServer } from './server.js' // now sees env varsCalling dotenv.config() manually in ESM runs after your other imports were already evaluated; the dotenv/config side-effect import avoids that.
Load a .env file from a custom locationcustom-path
require('dotenv').config({ path: '/custom/path/to/.env' })Default resolution is path.resolve(process.cwd(), '.env'), which depends on where you launch the process from, not where the file lives.
Layer multiple .env filesmultiple-files
require('dotenv').config({ path: ['.env.local', '.env'] })The first file wins for duplicate keys (no overwriting by default); pass override: true to reverse that.
Overwrite variables already set on the machineoverride-existing
require('dotenv').config({ override: true })By default dotenv never replaces an existing process.env value, so a stale exported shell variable silently beats your .env file.
Silence the v17 startup log linequiet-logging
require('dotenv').config({ quiet: true })
// or: DOTENV_CONFIG_QUIET=true node index.jsSince v17, config() prints an 'injected env' tip to stdout by default; this breaks CLIs whose output gets piped or parsed.
Parse env-format text without touching process.envparse-string
const dotenv = require('dotenv')
const config = dotenv.parse(Buffer.from('BASIC=basic'))
console.log(config) // { BASIC: 'basic' }parse() only returns an object; it never writes to process.env, which makes it safe for reading env-format files from S3, vaults, etc.
Run any command with .env applied (no code changes)cli-run
npx dotenv run -- node index.js
# pick specific files, first one wins:
npx dotenv run -f .env.local -f .env -- node index.jsThe dotenv run CLI arrived in recent v17 releases; flags take precedence over DOTENV_CONFIG_* environment variables.
Store a multiline private key in .envmultiline-values
# .env (works as-is since v15)
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
Kh9NV...
-----END RSA PRIVATE KEY-----"Line breaks inside double quotes are supported since v15; on older versions you must use \n escapes inside one quoted line.
Expand variables inside .env valuesvariable-expansion
// dotenv itself does NOT expand ${VARS}; add dotenv-expand:
const dotenv = require('dotenv')
const dotenvExpand = require('dotenv-expand')
dotenvExpand.expand(dotenv.config())
// .env: DATABASE_URL="postgres://${USER}@localhost/db"Plain dotenv treats ${USER} as a literal string; expansion needs dotenv-expand or dotenvx.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @dotenvx/dotenvx | npm | You want encrypted .env files, variable expansion, and multi-environment workflows from the same author |
| envalid | npm | You want your env vars validated and typed at startup instead of silently undefined at runtime |
| dotenv-flow | npm | You want automatic .env.development / .env.production / .env.local layering keyed off NODE_ENV |