NodeJSEnvironment Variables with dotenv

Environment Variables with dotenv

Setting a dozen export FOO=bar lines by hand every time you open a terminal is painful, so locally we keep config in a .env file — a simple list of KEY=value pairs. dotenv is the tiny, ubiquitous library that reads that file and loads its contents into process.env at startup. It's a development convenience: it makes local config easy without changing how your code reads values (still process.env.X), while production injects the real variables directly. This page covers the .env format, loading it, the absolutely-critical .gitignore rule, multiple environments, and the built-in Node support that's starting to replace the library.

The .env file and loading it

.env

Bash
# KEY=value pairs, one per line. # starts a comment.
PORT=3000
DATABASE_URL=postgres://localhost:5432/app_dev
JWT_SECRET=dev-only-secret-change-me
LOG_LEVEL=debug
FEATURE_NEW_CHECKOUT=true        # still a STRING "true" in process.env

JS
import 'dotenv/config'        // load .env into process.env — do this FIRST, before other imports

// Now everything is available on process.env, exactly as if it were a real env var:
const port = Number(process.env.PORT) || 3000
console.log(process.env.DATABASE_URL)
`dotenv` reads `.env` into `process.env` — import it before anything that reads config, and it never overrides real env vars
Install `dotenv` and load it as early as possible — `import 'dotenv/config'` (or `require('dotenv').config()`) as the **very first line**, before any module that reads `process.env` runs, or those modules will see `undefined`. It parses `.env` (`KEY=value` lines, `#` comments, optional quotes for values with spaces) and populates `process.env`. A key behavior: by default `dotenv` **does not override** variables that are *already* set in the real environment — so a value injected by your platform in production wins over the `.env` file, which is exactly what you want. After loading, your code reads config the same way everywhere (`process.env.X`), unaware of whether it came from a file or the real environment. Remember the values are still **strings** — coerce numbers and booleans yourself.
The #1 rule — never commit .env

.gitignore

Bash
# .env holds real secrets — it must NEVER be committed:
.env
.env.local
.env.*.local

# DO commit a template showing which vars are needed (no real values):
# .env.example

.env.example (committed — a checklist, not secrets)

Bash
PORT=3000
DATABASE_URL=
JWT_SECRET=
LOG_LEVEL=debug
Add `.env` to `.gitignore` immediately — committing it leaks secrets into git history permanently; commit `.env.example` instead
The single most important rule with `.env` files: **never commit them**. A `.env` typically holds real secrets (database passwords, API keys, JWT secrets), and committing it leaks them into git history *permanently* — deleting the file later doesn't help, because the values remain recoverable in past commits, so the only fix is rotating every exposed credential. Add `.env` (and `.env.local`, `.env.*.local`) to **`.gitignore` before your first commit**. To document *which* variables the app needs without exposing values, commit a **`.env.example`** with the keys and blank or placeholder values — new developers copy it to `.env` and fill in real secrets. This gives you a self-documenting config contract in the repo and zero leaked secrets. Treat a `.env` that *was* committed as a security incident: rotate the keys.
Multiple environments

Bash
# A common convention: per-environment files, selected at startup.
   .env                 # shared/local defaults (git-ignored)
   .env.development     # dev-specific
   .env.test            # test-specific (e.g. a throwaway test DB)
   .env.production      # usually NOT used — prod injects real env vars instead

JS
// Load a file chosen by NODE_ENV (with dotenv's path option):
import dotenv from 'dotenv'
dotenv.config({ path: `.env.${process.env.NODE_ENV || 'development'}` })
Use per-environment files for local dev/test — but in real production, inject variables directly, not from a `.env` file
You often want different local values per environment — a separate database for `test` so it can be wiped, debug logging in `development`. The common convention is per-environment files (`.env.development`, `.env.test`) selected via [`NODE_ENV`](/nodejs/node-env), loaded with `dotenv`'s `path` option. But an important caveat: **real production should not rely on a `.env` file at all**. Production secrets belong in the platform's secret management — Kubernetes Secrets, Docker/Compose env, your PaaS's config dashboard, AWS Secrets Manager — injected as actual environment variables, which are more secure and auditable than a file sitting on disk. Think of `.env` files as a **local-development and CI convenience**; in production, the real environment is the source of truth.
Built-in support — Node's native `--env-file`

Bash
# Node 20.6+ can load a .env file natively — no dotenv dependency:
node --env-file=.env app.js

# Multiple files (later overrides earlier), Node 20.12+:
node --env-file=.env --env-file=.env.local app.js
Node 20.6+ has a built-in `--env-file` flag — for simple cases you may not need the dotenv package at all
Recent Node versions (20.6+) include native `.env` loading via the **`--env-file`** flag, so `node --env-file=.env app.js` populates `process.env` with no dependency and no `import 'dotenv/config'` line — and you can pass the flag multiple times to layer files. For straightforward cases this can replace the `dotenv` package entirely. The library still has reasons to exist: programmatic control (`config()` options, parsing into a variable instead of `process.env`), variable expansion via `dotenv-expand`, encrypted `.env` vaults, and working on older Node versions. Either way the mental model is identical — a `.env` file feeds `process.env` for local development — so use the built-in flag when it's enough and the package when you need its extras.
Next
The one environment variable that changes everything: [NODE_ENV & Environments](/nodejs/node-env).