Environment & Config Management
Every non-trivial Express application runs in more than one place: your laptop, a CI runner, a staging box, and production. Each of those environments needs different database URLs, API keys, log levels, and feature flags — but the code that reads them should be identical. Treating configuration as data that lives outside your codebase, validated once at boot, is what keeps deployments predictable and secrets out of your Git history.
The 12-factor config principle
The Twelve-Factor App methodology draws a hard line: anything that varies between deploys belongs in the environment, not in the code. That means no config.production.json checked into the repo, no if (host === 'prod.example.com') branches, and no secrets baked into a Docker image. Configuration is supplied as environment variables, and the app reads them through a single typed module.
The payoff is concrete. You can promote the exact same artifact (a Git commit or container image) from staging to production by changing only the environment, and there is no risk of accidentally committing a credential because credentials never live in a file you track.
A useful test: could you open-source your repository right now without leaking a single secret? If the answer is no, configuration has leaked into the code.
Loading variables with dotenv
In development you don’t want to export a dozen variables by hand, so dotenv reads a local .env file into process.env. In staging and production the platform (Docker, Kubernetes, your PaaS) injects the real variables, so the .env file is absent and that’s fine.
npm install dotenv
Create a .env file for local work and add it to .gitignore:
# .env (never commit this)
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://localhost:5432/myapp_dev
SESSION_SECRET=dev-only-not-a-real-secret
LOG_LEVEL=debug
Load it as the very first thing your process does, before any module that reads config:
// load-env.js — imported at the top of the entry file
import dotenv from "dotenv";
dotenv.config();
Commit a
.env.examplethat lists every required key with placeholder values. It documents the contract for new developers and CI without exposing real secrets.
Validating required vars at boot
The single most valuable habit is fail-fast validation. A missing DATABASE_URL should crash the process at startup with a clear message — not surface as a cryptic connection error on the first request three hours later. Libraries like envalid or zod make this declarative.
// config.js
import "dotenv/config";
import { z } from "zod";
const schema = z.object({
NODE_ENV: z.enum(["development", "staging", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
SESSION_SECRET: z.string().min(16),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment configuration:");
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const config = parsed.data;
If SESSION_SECRET is missing, the process exits immediately:
Output:
Invalid environment configuration:
{ SESSION_SECRET: [ 'Required' ], DATABASE_URL: [ 'Required' ] }
Note z.coerce.number() — every environment variable is a string, so numeric and boolean values must be coerced. Importing this config module anywhere in the app guarantees the values are present and correctly typed.
Layering config per environment
Resist the urge to scatter process.env reads across your route handlers. Centralize everything in the config module above and derive per-environment behavior from NODE_ENV. Wiring it into the app stays trivial:
// app.js
import express from "express";
import morgan from "morgan";
import { config } from "./config.js";
const app = express();
// Environment-driven middleware
if (config.NODE_ENV === "production") {
app.set("trust proxy", 1); // behind a reverse proxy
app.use(morgan("combined"));
} else {
app.use(morgan("dev"));
}
app.get("/health", (req, res) => {
res.json({ status: "ok", env: config.NODE_ENV });
});
app.listen(config.PORT, () => {
console.log(`Listening on :${config.PORT} (${config.NODE_ENV})`);
});
export default app;
NODE_ENV is special: Express itself, and many middleware packages, change behavior based on it. In production Express caches view templates and emits terse error pages, so always set it explicitly.
NODE_ENV | Typical use | Logging | Error detail |
|---|---|---|---|
development | Local laptop | dev (colored) | Full stack traces |
staging | Pre-prod mirror of prod | combined | Limited |
production | Live traffic | combined + aggregation | Generic messages |
Secrets handling
Database passwords, signing keys, and third-party tokens deserve stricter treatment than ordinary config. The same process.env interface reads them, but how they get there differs by platform:
- Containers / Kubernetes — mount secrets as environment variables from a
Secretobject or a vault sidecar, never via--env-filebaked into the image. - Managed platforms (Render, Railway, Fly.io, AWS App Runner) — set them in the dashboard or CLI; they are injected at runtime and encrypted at rest.
- Secret managers (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) — fetch at boot and populate
process.envbefore validation.
Rotate secrets without redeploying code, and scope each environment to its own credentials so a staging leak can never touch production data.
Express 5.x note
Express 5 is promise-aware: a rejected promise in an async handler propagates to your error middleware automatically, so config-dependent setup like a database probe can throw safely.
app.get("/ready", async (req, res) => {
await db.query("SELECT 1"); // rejection auto-forwarded in Express 5
res.json({ ready: true, db: config.DATABASE_URL.split("@").pop() });
});
Best Practices
- Read configuration through one validated module; never sprinkle
process.envacross handlers. - Validate and coerce all variables at boot and
process.exit(1)on failure — fail fast, not on first request. - Keep
.envout of version control and commit a.env.exampledocumenting every key. - Set
NODE_ENVexplicitly in every environment so Express and middleware optimize correctly. - Never bake secrets into images or config files; inject them at runtime from the platform or a secret manager.
- Give each environment its own credentials and rotate secrets independently of code deploys.
- Mask secrets in logs and health output — log a host or a fingerprint, never the full connection string.