Database URLs, API keys and port numbers differ between your laptop, a CI runner and production. Hard-coding them breaks at least one of those environments and leaks secrets into Git. Express apps solve this with environment variables read through process.env. After this lesson you will be able to load a .env file, validate configuration at startup, and switch behaviour with NODE_ENV.
Every Node process inherits the environment of the shell that started it. Values are always strings, so convert numbers and booleans yourself:
PORT=4000 node src/server.jsconst port = Number(process.env.PORT ?? 3000);
const debug = process.env.DEBUG === "true";Typing variables on the command line does not scale, which is where .env files come in.
npm install dotenvCreate .env in the project root and add it to .gitignore immediately:
# .env
NODE_ENV=development
PORT=3000
MONGO_URI=mongodb://localhost:27017/todos
JWT_SECRET=change-me-in-productionLoad it before any other module reads process.env. The dotenv/config import does this as a side effect:
// first line of src/server.js or src/config/index.js
import "dotenv/config";Commit a .env.example with the same keys and placeholder values so teammates know what to define. Existing shell variables always win over .env values, which lets a host platform override defaults without editing files.
Node itself can now load the file without a package: node --env-file=.env src/server.js (Node 20.6+), or process.loadEnvFile() inside code (Node 20.12+). dotenv remains popular because it works identically across versions and with tools like Jest.
Scattering process.env.X across the codebase makes it impossible to see what an app needs. Centralise reads in one module, convert types once, and fail fast when something is missing:
// src/config/index.js
import "dotenv/config";
const required = (name) => {
const value = process.env[name];
if (!value) throw new Error(`Missing environment variable: ${name}`);
return value;
};
export const config = Object.freeze({
env: process.env.NODE_ENV ?? "development",
port: Number(process.env.PORT ?? 3000),
mongoUri: required("MONGO_URI"),
jwtSecret: required("JWT_SECRET"),
corsOrigins: (process.env.CORS_ORIGINS ?? "").split(",").filter(Boolean),
isProduction: process.env.NODE_ENV === "production",
});A missing JWT_SECRET now crashes at boot with a clear message instead of producing unsigned tokens at 3 a.m. For larger apps, schema libraries such as Zod (covered in the validation lesson) or envalid give the same guarantee with less code.
NODE_ENV is a convention that libraries respect. Express reads it into app.get("env") and, when it equals "production", caches compiled templates and hides error stack traces from responses. Typical switches:
| Setting | development | production |
| --- | --- | --- |
| Logging format | morgan("dev") colours | JSON lines |
| Error responses | include stack | message only |
| View cache | off | on |
| Cookie secure flag | false (HTTP) | true (HTTPS) |
Hosting platforms usually set NODE_ENV=production for you; set it explicitly in Docker images and PM2 ecosystem files to be sure.
process.env at module top level before dotenv loaded. Import dotenv/config first or import the config module everywhere instead of process.env..env. If it happened, rotate every secret in it; removing the file from history is not enough..env in production containers. Inject variables through the orchestrator or platform dashboard, and keep real secrets in a secrets manager.process.env.PORT is a number. It is a string until you convert it.What type does `process.env.PORT` have?
.env files make them convenient in development and must never be committed.import "dotenv/config" (or node --env-file) before anything reads process.env.NODE_ENV=production changes Express defaults such as view caching and error verbosity..env.example and inject real secrets through the hosting platform or a secrets manager.Next lesson: Template Engines with EJS — render dynamic HTML pages on the server with layouts, partials and data from your routes.