Database URLs, API keys, ports and feature toggles shouldn’t be hard-coded. In Node.js, configuration comes from environment variables, read through process.env. Here’s how to load them in development, set them properly in production (PM2, systemd, Docker), and avoid common mistakes.

Reading variables

const port = Number(process.env.PORT ?? 3000);
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) throw new Error("DATABASE_URL is required");

All values are strings. Convert numbers and booleans explicitly (process.env.DEBUG === "true").

.env files

# .env (never commit this file)
PORT=3000
DATABASE_URL=postgres://app:[email protected]:5432/app
REDIS_URL=redis://127.0.0.1:6379
JWT_SECRET=long-random-string

Node.js 20.6+ loads them natively:

node --env-file=.env server.js
node --env-file=.env --env-file=.env.local server.js   # later files override

Older Node versions, or more features, use dotenv:

require("dotenv").config();           // or: import "dotenv/config";

Commit a .env.example with placeholder values, and add .env to .gitignore. See .gitignore guide.

Validate at startup

Fail fast with a clear message, instead of crashing later:

import { z } from "zod";

const Env = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
});
export const env = Env.parse(process.env);

NODE_ENV=production

Many libraries (Express, React SSR, ORMs) enable optimisations and disable verbose errors when NODE_ENV=production. Set it in every production environment. npm ci --omit=dev skips devDependencies too.

Setting variables in production

PM2 (ecosystem file):

module.exports = {
  apps: [{
    name: "api",
    script: "server.js",
    node_args: "--env-file=/opt/app/.env",
    env: { NODE_ENV: "production" },
  }],
};

After changing variables: pm2 restart api --update-env. See PM2 process manager.

systemd:

[Service]
User=app
WorkingDirectory=/opt/app
EnvironmentFile=/opt/app/.env
Environment=NODE_ENV=production
ExecStart=/usr/bin/node server.js

See systemd unit files.

Docker Compose:

services:
  api:
    image: registry.example.com/api:1.8.3
    env_file: .env
    environment:
      NODE_ENV: production

See Docker environment variables.

Keep secrets safe

  • .env on the server with chmod 600, owned by the app user.
  • Don’t log process.env, or full config objects.
  • Environment variables are visible to the process owner and root (/proc/<pid>/environ), and in docker inspect. For higher security, mount secrets as files (Docker secrets, Kubernetes Secrets), and read them at startup. See Docker secrets.
  • Use different secrets for development, staging and production, and rotate them after leaks or staff changes. See secrets management.

Frontend builds: careful

Variables used in frontend bundles (VITE_*, NEXT_PUBLIC_*, REACT_APP_*) are embedded in public JavaScript at build time. Never put secrets there. Only public values (API base URL, public keys).

Common mistakes

Mistake Fix
.env committed to Git Remove, rotate secrets, add to .gitignore
Variable undefined under PM2/systemd Not loaded there: use --env-file, EnvironmentFile=
Changes not picked up Restart the process (--update-env for PM2)
"false" treated as true Compare strings explicitly
Secrets in frontend env vars Only public values in VITE_/NEXT_PUBLIC_

Quick reference

Task How
Read process.env.NAME
Load .env (Node 20.6+) node --env-file=.env app.js
Load .env (older) require("dotenv").config()
Validate zod/envalid at startup
systemd EnvironmentFile=
Docker env_file:

Related: twelve-factor app and Node.js cluster mode.

  • Node.js
  • Configuration
  • Security