You read an environment variable in Node.js with process.env:
const port = process.env.PORT;
const key = process.env.API_KEY;process.env is a plain object Node populates at startup from the environment the process inherited. Reading a value is just a property lookup. That part has not changed since Node existed, and it is all most people actually need.
What has changed is the part everyone reaches for next: loading a .env file. For years the answer was npm install dotenv. As of Node 20.6 (September 2023) it is built in, so for the common case you can drop the dependency entirely. I will cover both, but lead with the native path because it is the one I now use.
Reading and writing process.env
Every value on process.env is a string, or undefined if the variable was never set:
console.log(process.env.PORT); // "3000" (a string, not a number)
console.log(process.env.NOT_SET); // undefinedYou can assign to it too, which sets the variable for the current process and any child processes it spawns:
process.env.LOG_LEVEL = "debug";Assigning coerces the value to a string (process.env.RETRIES = 5 stores "5"). Setting a variable in Node does not write it back to your shell; it lives and dies with the process.
Load a .env file natively (no dotenv)
Put your config in a .env file, one KEY=value per line:
# .env
PORT=3000
DATABASE_URL=postgres://localhost:5432/app
API_KEY=sk_live_not_a_real_keyThen point Node at it with --env-file when you start the app:
node --env-file=.env app.jsNode parses the file and merges it into process.env before your code runs. No require("dotenv").config() at the top of your entry file, no dependency in package.json. Inside the app, process.env.PORT is populated exactly as if you had exported it in the shell. The flag landed experimental in 20.6, and Node marked it stable in 24.10 and 22.21, so on a current LTS it runs with no experimental warning.
Two behaviours worth knowing. If a variable is already set in the real environment, the shell value wins, the file does not override it. And you can pass --env-file more than once, with later files overriding earlier ones:
node --env-file=.env --env-file=.env.local app.jsThat layering (.env for defaults, .env.local for machine-specific overrides) is the pattern dotenv users hand-rolled with multiple config() calls. It is native now. If a file is missing and you would rather not error, use --env-file-if-exists=.env instead.
Loading from inside your code
If you need to load the file programmatically rather than via a CLI flag (a script you run different ways, a test harness), use process.loadEnvFile():
process.loadEnvFile(); // loads ./.env
process.loadEnvFile(".env.test");Called with no argument it reads .env from the current working directory; pass a path for anything else. This is the native equivalent of dotenv.config(). It arrived after the CLI flag (Node 21.7, backported to 20.12) and is stable on current Node. There is also util.parseEnv(), added in the same 21.7 release, if you want to parse a .env string yourself without touching process.env.
When you still want dotenv
Native loading covers the everyday case, but reach for dotenv when:
- You support Node before 20.6. The flag and
process.loadEnvFile()simply do not exist there. - You need variable expansion, e.g. one var referencing another. Node's native parser does not expand;
dotenv-expanddoes. - You rely on dotenv's richer parsing or its ecosystem of plugins. The native parser already handles multiline values (since Node 21.7) and quoted strings, so check whether you actually need dotenv for this before adding it.
For a brand-new app on current Node with a flat .env, I skip it. For a library that has to run on whatever Node a consumer has, I still ship dotenv as the safe floor.
The NODE_ENV convention
Use process.env.NODE_ENV, not a homegrown MODE or APP_ENV, to signal the runtime environment. It is the convention the whole ecosystem reads: Express tunes error output by it, many libraries strip dev-only code when it is "production", and bundlers key on it.
const isProd = process.env.NODE_ENV === "production";Set it where you start the app:
export NODE_ENV=production
node app.jsTreat it as a fixed three-way value (development, production, test) rather than an arbitrary string. Anything you compare against "production" should set it to exactly that.
Never commit .env
A .env file holds secrets: database passwords, API keys, tokens. It must not go into git. Add it to .gitignore before your first commit:
# .gitignore
.env
.env.local
.env.*.localCommit a .env.example instead, with the keys present and the values blanked or faked, so a new contributor knows what to set without learning your secrets:
# .env.example
PORT=3000
DATABASE_URL=
API_KEY=In production, do not ship a .env at all. Inject variables through the host's mechanism: the dashboard env vars on your PaaS, Docker -e / env_file, Kubernetes secrets, or a secret manager. The file is a local-development convenience; the real environment is where production config belongs.
Reading required and typed values safely
process.env.X is undefined when unset and always a string when set, so two bugs are common: a missing var that silently becomes undefined, and a number used as a string. Fail fast on the first, convert explicitly for the second:
function te_required(name) {
const value = process.env[name];
if (value === undefined || value === "") {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function te_intEnv(name, fallback) {
const raw = process.env[name];
if (raw === undefined) return fallback;
const n = Number(raw);
if (Number.isNaN(n)) throw new Error(`${name} must be a number, got "${raw}"`);
return n;
}
const dbUrl = te_required("DATABASE_URL");
const port = te_intEnv("PORT", 3000);Validating at startup means a misconfigured deploy crashes immediately with a clear message, instead of a connect to undefined error three layers deep an hour later. For anything beyond a handful of vars, a schema validator (zod, envalid) earns its place; for a small service, the two helpers above are enough.
Setting a variable cross-platform
For a one-off run, set the variable inline. The syntax differs by shell:
API_KEY=secret node app.jsThe API_KEY=secret node app.js form (export-style, prefixing the command) works in bash and zsh on macOS and Linux but not in Windows cmd or PowerShell, which is exactly the npm-script portability trap. If your package.json scripts set vars inline and you have Windows contributors, use cross-env so one script line works everywhere:
npm install --save-dev cross-env{
"scripts": {
"start": "cross-env NODE_ENV=production node app.js"
}
}PowerShell's own syntax is $env:NODE_ENV="production"; node app.js, different again from cmd, which is the whole reason cross-env exists.
See also
- Fix EADDRINUSE: port already in use in Node.js: the error you hit when
process.env.PORTcollides with something already bound. - JavaScript Promises: a complete guide: the async foundation most env-driven config loading sits on top of.
- How to update your Node.js version: get to 20.6+ so the native
--env-fileloader is available. - Set environment variables in Docker: how the same vars get injected in a container, where there is no
.envfile to load. - Configure a WordPress .env file: the equivalent pattern for a PHP/WordPress stack.
Sources
Authoritative references this article was fact-checked against.





