Skip to content
Node.js nd libraries 4 min read

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:

OptionTypePurpose
pathstring | string[]Custom .env location(s), e.g. .env.local
encodingstringFile encoding (default utf8)
overridebooleanLet .env overwrite existing process.env values
debugbooleanLog parsing details to help diagnose issues
quietbooleanSuppress 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. Pass override: true only 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 with dotenv (and dotenv-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 .env files as secrets: gitignore them and commit a .env.example template instead.
  • Prefer the native --env-file flag for simple setups; reach for the dotenv package when you need expansion or programmatic access.
  • Never let .env override real production environment variables — keep override off 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 on NODE_ENV in code.
  • Parse and coerce values into a typed config object so the rest of your app never touches raw strings from process.env.
Last updated June 14, 2026
Was this helpful?