Managing Secrets & Env Vars
Every Express app needs configuration that differs between machines — database URLs, API keys, JWT signing secrets, SMTP passwords. The cardinal rule, codified by the Twelve-Factor App methodology, is that this configuration belongs in the environment, never in your source code. Hardcoding a secret means it lives forever in git history, ships to every clone of the repo, and can leak the moment the code becomes public. This page covers loading config from process.env, using dotenv for local development, keeping a real secrets manager in production, and failing fast when a required variable is missing.
Reading configuration from the environment
Node exposes every environment variable through process.env, where each value is always a string (or undefined). The cleanest pattern is to read and coerce every variable once, in a single module, then import the typed object everywhere else rather than scattering process.env reads across the codebase.
// config.js — the single source of truth for runtime config
const config = {
port: Number(process.env.PORT) || 3000,
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
nodeEnv: process.env.NODE_ENV || "development",
isProduction: process.env.NODE_ENV === "production"
};
module.exports = config;
// app.js
const express = require("express");
const config = require("./config");
const app = express();
app.get("/health", (req, res) => {
res.json({ status: "ok", env: config.nodeEnv });
});
app.listen(config.port, () => {
console.log(`Listening on port ${config.port}`);
});
Centralizing access means a typo like process.env.JWT_SECERT happens in exactly one file, and you can validate everything in one place (see below).
Using dotenv for local development
In production the platform injects environment variables for you, but on a developer’s laptop that is inconvenient. The dotenv package reads a local .env file and loads its key/value pairs into process.env, so local runs mirror production without exporting variables by hand.
npm install dotenv
Create a .env file in the project root. It is a flat list of KEY=value lines — no quotes, no export:
PORT=3000
DATABASE_URL=postgres://app:devpass@localhost:5432/myapp
JWT_SECRET=local-dev-secret-not-for-prod
NODE_ENV=development
Load it as the very first thing your process does, before any module that reads config:
// Must run before ./config or any DB client is required.
require("dotenv").config();
const config = require("./config");
// ...rest of the app
Load
dotenvat the top of your entry file only. Callingdotenv.config()deep inside a module that other files have already imported is a classic source of “my variable isundefined” bugs — by then the importing modules have already readprocess.env.
Never commit your .env file
Your .env holds real secrets, so it must never enter version control. Add it to .gitignore immediately, and commit a .env.example template that documents the required keys with dummy values instead.
# .gitignore
.env
.env.local
.env.*.local
# .env.example (this file IS committed — no real values)
PORT=3000
DATABASE_URL=
JWT_SECRET=
NODE_ENV=development
If a secret was ever committed, removing it in a later commit is not enough — it remains in history. Rotate (regenerate) the leaked credential at its source and, if needed, scrub history with a tool like git filter-repo.
Validating required variables at startup
A missing or malformed secret should crash the app loudly on boot, not surface as a cryptic 500 an hour later when the first request hits the database. Validate the whole config object up front. A schema library such as Zod or envalid makes this clean and gives you coercion for free.
// config.js
const { z } = require("zod");
const schema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 chars"),
NODE_ENV: z.enum(["development", "test", "production"]).default("development")
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error("Invalid environment configuration:");
console.error(parsed.error.flatten().fieldErrors);
process.exit(1); // fail fast — do not start a half-configured server
}
module.exports = parsed.data;
If you start the app with JWT_SECRET unset, it refuses to boot:
Output:
Invalid environment configuration:
{
DATABASE_URL: [ 'Invalid url' ],
JWT_SECRET: [ 'JWT_SECRET must be at least 32 chars' ]
}
This turns a whole class of latent runtime failures into an instant, obvious startup error.
Secrets in production
dotenv is a development convenience; in production you want a managed secrets store that supports rotation, access control, and audit logging. The right choice depends on where you deploy.
| Approach | Where it fits | Notes |
|---|---|---|
| Platform env vars | Heroku, Render, Railway, Fly.io | Set in the dashboard or CLI; simplest, no extra SDK |
| Container/orchestrator secrets | Kubernetes Secrets, Docker Swarm | Mounted as env vars or files; pair with RBAC |
| Cloud secrets manager | AWS Secrets Manager, GCP Secret Manager, Azure Key Vault | Supports rotation and fine-grained IAM; fetch at boot |
| Dedicated vault | HashiCorp Vault | Dynamic, short-lived credentials and leasing |
When using a cloud secrets manager, fetch the secrets during startup and populate process.env (or your config object) before the server begins accepting traffic.
// Pseudocode for an AWS Secrets Manager fetch at boot.
const { SecretsManagerClient, GetSecretValueCommand } =
require("@aws-sdk/client-secrets-manager");
async function loadSecrets() {
const client = new SecretsManagerClient({});
const res = await client.send(
new GetSecretValueCommand({ SecretId: "prod/myapp/config" })
);
Object.assign(process.env, JSON.parse(res.SecretString));
}
async function start() {
if (process.env.NODE_ENV === "production") {
await loadSecrets();
} else {
require("dotenv").config();
}
const config = require("./config"); // validates after secrets are loaded
const app = require("./app")(config);
app.listen(config.port);
}
start().catch((err) => {
console.error("Startup failed:", err);
process.exit(1);
});
Express 5 changes nothing about configuration loading — these patterns are identical on 4.x and 5.x, since they run before any routing.
Best Practices
- Read
process.envin exactly one config module and import that everywhere; never sprinkle rawprocess.envaccess through route handlers. - Validate all required variables at startup with a schema and
process.exit(1)on failure — fail fast, never half-configured. - Add
.envto.gitignoreand commit a.env.exampletemplate with empty or dummy values. - If a secret leaks into git history, rotate the credential at its source; deleting the file in a later commit does not undo the exposure.
- Use a managed secrets manager (with rotation and audit logs) in production rather than shipping a
.envfile to servers. - Keep separate secrets per environment (dev, staging, prod) so a compromised dev key can never touch production data.
- Never log
process.envor secret values, and scrub them from error reporters like Sentry.