dotenv: Environment Configuration
Configuration that changes between environments — database URLs, API keys, ports, feature flags — should never be hard-coded into your source. The twelve-factor app methodology recommends storing this kind of config in the environment, and dotenv is the de facto tool for doing that in Node.js. It reads a plain-text .env file at startup and loads the keys into process.env, so your code reads process.env.DATABASE_URL whether the value comes from a file in development or from a real environment variable in production.
Installing and creating a .env file
Install dotenv as a regular dependency (you need it at runtime, not just during development):
npm install dotenv
Create a .env file in your project root. It is a simple list of KEY=VALUE pairs, one per line. Lines starting with # are comments, and surrounding quotes are stripped:
# .env
PORT=3000
DATABASE_URL="postgres://localhost:5432/app"
JWT_SECRET=super-secret-change-me
# Multi-word values don't need quotes, but quotes make intent clear
APP_NAME="DevCraftly API"
Loading variables with dotenv.config
The simplest approach is to call config() as early as possible — ideally before any other module reads process.env. In ES modules, import dotenv and invoke it at the top of your entry file:
import "dotenv/config";
import express from "express";
const app = express();
const port = process.env.PORT ?? 3000;
app.get("/", (req, res) => {
res.json({ app: process.env.APP_NAME });
});
app.listen(port, () => {
console.log(`Listening on http://localhost:${port}`);
});
The bare import "dotenv/config" is a side-effect import that calls config() for you. If you need the return value — for example to inspect parsing errors — call it explicitly instead:
import dotenv from "dotenv";
const result = dotenv.config();
if (result.error) {
console.error("Failed to load .env:", result.error);
}
console.log("Loaded keys:", Object.keys(result.parsed ?? {}));
Output:
Loaded keys: [ 'PORT', 'DATABASE_URL', 'JWT_SECRET', 'APP_NAME' ]
In CommonJS the equivalent first line is require("dotenv").config();. The config() function accepts options to customize behaviour:
| Option | Type | Purpose |
|---|---|---|
path | string | string[] | Custom .env location(s), e.g. .env.local |
encoding | string | File encoding (default utf8) |
override | boolean | Let .env overwrite existing process.env values |
debug | boolean | Log parsing details to help diagnose issues |
quiet | boolean | Suppress the startup tips logged by recent versions |
import dotenv from "dotenv";
dotenv.config({
path: [".env.local", ".env"],
override: false,
quiet: true,
});
By default dotenv never overwrites a variable that is already set in
process.env. This is intentional: real environment variables (from your shell, CI, or container orchestrator) should win over a checked-in file. Passoverride: trueonly when you explicitly want the file to take precedence.
The modern --env-file flag
Node.js 20.6+ ships with built-in .env support, so for many projects you no longer need the dotenv package at all at runtime. Pass --env-file on the command line and Node loads the file before running your script:
node --env-file=.env server.js
You can supply the flag multiple times; later files override earlier ones, which is handy for layering defaults and local overrides:
node --env-file=.env --env-file=.env.local server.js
In Node 22 you can also use --env-file-if-exists to avoid an error when the file is missing — useful in CI where the variables come from the platform instead. Add the flag to your npm scripts so everyone runs the app the same way:
{
"scripts": {
"dev": "node --env-file=.env --watch server.js",
"start": "node server.js"
}
}
The native loader does not currently support variable expansion or the full feature set of the dotenv package. If you rely on
${VAR}references, multiline values, or programmatic parsing, stick withdotenv(anddotenv-expand).
Variable expansion
Plain dotenv treats values as literal strings. To reference one variable inside another, add the dotenv-expand package, which post-processes the parsed values:
npm install dotenv dotenv-expand
# .env
HOST=localhost
PORT=5432
DATABASE_URL=postgres://${HOST}:${PORT}/app
import dotenv from "dotenv";
import { expand } from "dotenv-expand";
expand(dotenv.config());
console.log(process.env.DATABASE_URL);
Output:
postgres://localhost:5432/app
Expansion supports defaults with ${VAR:-fallback}, so missing values degrade gracefully instead of producing broken strings.
Keeping .env out of version control
A .env file usually contains secrets, so it must never be committed. Add it to .gitignore and commit a sanitized template instead:
# .gitignore
.env
.env.local
.env.*.local
Commit a .env.example with the same keys but blank or placeholder values. New contributors copy it to .env and fill in real secrets:
cp .env.example .env
To validate that required variables are actually present at boot, fail fast rather than crashing deep inside a request handler:
import "dotenv/config";
const required = ["DATABASE_URL", "JWT_SECRET"];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
console.error(`Missing required env vars: ${missing.join(", ")}`);
process.exit(1);
}
For richer validation and type coercion, pair this with a schema library such as Zod or Joi.
Best practices
- Load configuration once, as early as possible, before any module reads
process.env. - Treat
.envfiles as secrets: gitignore them and commit a.env.exampletemplate instead. - Prefer the native
--env-fileflag for simple setups; reach for the dotenv package when you need expansion or programmatic access. - Never let
.envoverride real production environment variables — keepoverrideoff so the platform always wins. - Validate required variables at startup and exit early with a clear message if any are missing.
- Use separate files (
.env.development,.env.test,.env.production) and layer them deliberately rather than branching onNODE_ENVin code. - Parse and coerce values into a typed config object so the rest of your app never touches raw strings from
process.env.