process.envNode exposes environment variables through process.env, a plain object of string key-value pairs inherited from the process that launched it:
const port = process.env.PORT || 3000;
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
throw new Error("DATABASE_URL is required but was not set");
}
Reading configuration this way — rather than hardcoding values — is what lets the exact same code run correctly in development, staging, and production: the behavior stays identical, only the values injected from the environment change (a local Postgres URL vs. a managed production database's URL, a permissive CORS origin vs. a locked-down one, verbose logging vs. quiet).
Development usually wants verbose logging, relaxed CORS, and a local database. Production wants the opposite: minimal logging (or structured logs shipped somewhere), strict CORS, connection pools sized for real traffic, and — critically — no secrets baked into the code or committed to source control. NODE_ENV (conventionally "development", "test", or "production") is the de facto standard variable a lot of libraries (Express included) check to decide things like whether to cache compiled templates or include stack traces in error responses.
Never commit real secrets — API keys, database passwords, signing secrets — to source control, even in a private repository. A
.envfile holding real values belongs in.gitignore; commit a.env.examplelisting the names of the variables a deployment needs, with placeholder or dummy values, so the next person (or your future self) knows what to set without ever seeing the real ones.
.env load-order gotchaHere's a genuinely common surprise: Node does not load .env files by default. process.env only contains variables that were actually set in the shell or process environment that launched Node — a .env file sitting in your project directory does nothing on its own. Historically, the standard fix has been the dotenv package:
require("dotenv").config(); // must run BEFORE any code that reads process.env
const dbUrl = process.env.DATABASE_URL; // now populated, if it's in .env
The ordering matters: dotenv.config() has to execute before any module that reads the variables it's meant to provide, which is why it's conventionally the very first line in an entry file. Get the order wrong — say, importing a database module that reads process.env.DATABASE_URL at import time, before dotenv.config() has run — and you'll chase a undefined value that only reproduces because of import order, not because the .env file is wrong. (Newer Node versions have added native --env-file support as an alternative, but plenty of existing code and tooling still relies on dotenv, and the same "load before you read" rule applies either way.)