Skip to content
Express.js ex libraries 4 min read

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 .env file.

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();
FileCommitted to git?Purpose
.envYesShared defaults for all environments
.env.developmentYesNon-secret development overrides
.env.testYesValues used by your test suite
.env.localNoMachine-specific secrets, all envs
.env.production.localNoProduction 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 .env with 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 like git filter-repo.

Best Practices

  • Call dotenv.config() (or import "dotenv/config") before any other import so no module reads process.env too early.
  • Never commit .env; commit a .env.example with 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.env throughout the codebase.
  • In production, prefer real platform-injected environment variables; let dotenv fill the gaps for local development only.
  • Use dotenv-flow or distinct .env.${NODE_ENV} files instead of swapping one .env by hand.
  • Keep secrets out of NODE_ENV=test files; use throwaway dummy values so CI never depends on production credentials.
Last updated June 14, 2026
Was this helpful?