dotenv for Config
Configuration that differs between machines — database URLs, API keys, ports, feature flags — should never be hard-coded or committed to source control. The dotenv library reads a plain .env file from your project root and copies its key/value pairs into process.env, giving you the convenience of a local config file with the safety of environment variables. It is the de facto standard for Express apps and has zero dependencies, so it costs you almost nothing to adopt.
Installing and loading dotenv
Install dotenv as a regular dependency, then call its config() function exactly once, as early as possible in your startup path. Anything that reads process.env before config() runs will see undefined values, so load it before you require your own modules.
npm install dotenv
Create a .env file in the project root:
# .env
PORT=4000
NODE_ENV=development
DATABASE_URL=postgres://localhost:5432/app
JWT_SECRET=dev-only-not-a-real-secret
Then load it at the very top of your entry file:
// server.js — this MUST be the first import
require("dotenv").config();
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/", (req, res) => {
res.json({ env: process.env.NODE_ENV, port });
});
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
Output:
Server listening on http://localhost:4000
Variables that already exist in the real environment win: by default dotenv will not overwrite a key that is already set in
process.env. This is intentional — your production platform’s injected variables take precedence over the.envfile.
Loading early in ESM and TypeScript
With ES modules and TypeScript, import statements are hoisted and run before any plain code, so a normal dotenv.config() call can execute too late. Use the side-effect import form, which dotenv ships specifically for this case, and place it above every other import.
// server.ts
import "dotenv/config";
import express from "express";
const app = express();
app.listen(Number(process.env.PORT) || 3000);
Per-environment files with dotenv-flow
A single .env works until you need different values for development, test, and production on the same machine. The dotenv-flow package layers multiple files based on NODE_ENV, loading them in a predictable cascade so shared defaults live in one place and overrides live in another.
npm install dotenv-flow
// Loads .env, then .env.local, then .env.${NODE_ENV},
// then .env.${NODE_ENV}.local — later files override earlier ones.
require("dotenv-flow").config();
| File | Committed to git? | Purpose |
|---|---|---|
.env | Yes | Shared defaults for all environments |
.env.development | Yes | Non-secret development overrides |
.env.test | Yes | Values used by your test suite |
.env.local | No | Machine-specific secrets, all envs |
.env.production.local | No | Production secrets for this host |
The .local variants are meant to hold secrets and are excluded from version control, while the base files carry safe defaults that every developer shares.
Validating your configuration
dotenv only copies strings into process.env — it never checks that required keys exist or that a port is a valid number. A missing JWT_SECRET will not fail at startup; it will fail later, at runtime, in a confusing way. Validate the environment once, at boot, and crash fast with a clear message if something is wrong. A small schema with Zod is a clean way to do this.
require("dotenv").config();
const { z } = require("zod");
const schema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(16),
});
const result = schema.safeParse(process.env);
if (!result.success) {
console.error("Invalid environment variables:");
console.error(result.error.flatten().fieldErrors);
process.exit(1);
}
// Use the parsed, type-coerced values everywhere instead of process.env.
module.exports = result.data;
Output (when JWT_SECRET is missing):
Invalid environment variables:
{ JWT_SECRET: [ 'Required' ] }
Because PORT is coerced to a number and DATABASE_URL is checked as a URL, the rest of your app can trust the exported config object instead of re-parsing raw strings.
Keeping .env out of git
A .env file frequently holds real secrets, so it must never reach your repository. Add it (and the .local overrides) to .gitignore, and commit a .env.example template instead so teammates know which keys to define.
# .gitignore
.env
.env.*.local
.env.local
# .env.example — committed, with placeholder values only
PORT=3000
NODE_ENV=development
DATABASE_URL=
JWT_SECRET=
If a
.envwith live credentials is ever committed, removing it in a later commit is not enough — the secret stays in git history. Rotate the exposed keys immediately and scrub the history with a tool likegit filter-repo.
Best Practices
- Call
dotenv.config()(orimport "dotenv/config") before any other import so no module readsprocess.envtoo early. - Never commit
.env; commit a.env.examplewith empty or placeholder values as living documentation. - Validate and coerce all variables at startup with a schema, then import the validated config object rather than touching
process.envthroughout the codebase. - In production, prefer real platform-injected environment variables; let dotenv fill the gaps for local development only.
- Use
dotenv-flowor distinct.env.${NODE_ENV}files instead of swapping one.envby hand. - Keep secrets out of
NODE_ENV=testfiles; use throwaway dummy values so CI never depends on production credentials.