The dotenv config call has to run before anything that reads process.env. In CommonJS that means the first line of your entry file. In ESM it does not work that way at all, and that is where most of the confusion lives.
Express itself knows nothing about env files - process.env is a Node concept and dotenv is the library that populates it from a file. Almost every problem with this setup is a sequencing problem, and ES modules make the sequencing less obvious than it used to be.
Table of contents
- The CommonJS setup
- The ESM trap
- Reading config in one place
- Files, precedence, and git
- Production: stop reading files
- How this fits the rest of the stack
- FAQ
The CommonJS setup
Install the package and call config before any other require that might read configuration.
npm install dotenv
// server.js - the config call must be first
require('dotenv').config()
const express = require('express')
const { pool } = require('./db')
const app = express()
const port = process.env.PORT || 3000
app.get('/health', (req, res) => res.json({ ok: true }))
app.listen(port, () => {
console.log(`listening on ${port}`)
})
# .env
PORT=3000
DATABASE_URL=postgres://localhost:5432/app_dev
SESSION_SECRET=change-me-locally
The ordering rule is not stylistic. If the database module reads its connection string at module scope - which most of them do, to build a connection pool - then requiring it before the config call means it reads undefined and builds a pool pointing nowhere.
There is a preload form that removes the question entirely, because it runs before your code does at all:
node -r dotenv/config server.js
That is the more robust option for production entry points, and it means the library call is not scattered through your source.
The ESM trap
With a module type declared in package.json, the CommonJS pattern breaks in a way that looks like it should work.
// BROKEN - all imports are hoisted and evaluated first
import dotenv from 'dotenv'
dotenv.config()
import { pool } from './db.js' // already ran, saw no env vars
ES module imports are hoisted. Every import in the file is resolved and evaluated before any statement in the module body runs, regardless of where it appears in the source. So the database module executes before the config call does, and putting the config call at the top does nothing.
There are three fixes, in rough order of preference.
// 1. Side-effect import - runs during the import phase
import 'dotenv/config'
import { pool } from './db.js'
import express from 'express'
# 2. Preload, no source change at all
node --env-file=.env server.js # Node 20.6+, no dependency
node -r dotenv/config server.js # any version, with dotenv
// 3. Dynamic import after config, if you need conditional loading
import dotenv from 'dotenv'
dotenv.config()
const { pool } = await import('./db.js')
Node 20.6 added a native env-file flag, which reads the file with no dependency at all. For new projects on a recent Node, that is the simplest answer: no package, no import, no ordering question.
Reading config in one place
Scattering environment lookups through a codebase means every typo is a silent undefined and every required variable is discovered at the moment it is needed. Centralising fixes both.
// config.js
import 'dotenv/config'
function required(name) {
const value = process.env[name]
if (!value) {
throw new Error(`Missing required environment variable: ${name}`)
}
return value
}
export default {
port: Number(process.env.PORT ?? 3000),
env: process.env.NODE_ENV ?? 'development',
databaseUrl: required('DATABASE_URL'),
sessionSecret: required('SESSION_SECRET'),
logLevel: process.env.LOG_LEVEL ?? 'info',
}
// Everywhere else
import config from './config.js'
app.listen(config.port)
Three things this buys you. The app crashes at startup with a named variable when something is missing, rather than throwing a connection error twenty minutes later. Every value is coerced once - note the numeric conversion on the port, because everything in the environment is a string and listening on a string behaves differently from listening on a number. And the file is a readable inventory of what the service needs.
The string coercion point deserves emphasis. An environment variable set to the text false is a truthy string. Testing it directly in a condition is true for both true and false, which is a bug that survives code review because it reads correctly.
Files, precedence, and git
Plain dotenv loads exactly one file and does not implement the precedence chain that Next.js and Vite provide. If you want that behaviour in Express, build it explicitly.
import dotenv from 'dotenv'
dotenv.config({ path: '.env' })
dotenv.config({ path: `.env.${process.env.NODE_ENV}`, override: true })
dotenv.config({ path: '.env.local', override: true })
Without the override flag, dotenv will not replace a variable that is already set - including ones set by an earlier config call. That default is deliberate, so real environment variables always beat the file, but it means the second and third calls above do nothing unless you ask.
# .gitignore
.env
.env.local
.env.*.local
# Committed instead:
# .env.example
For a backend service, gitignoring the env file entirely and committing an example is the right default. A backend env file almost always contains a real database URL, and the cost of that reaching a public repository is higher than the convenience of shared defaults.
Production: stop reading files
In production, do not ship an env file. Set real environment variables and let dotenv find nothing - it fails silently when the file is absent, so the same code path works in both places.
The argument is concrete. An env file baked into a container image is present in the image layers and in the registry, readable by anyone who can pull it. One copied in at deploy time is a second secret-delivery mechanism you have to build, secure, and rotate. Platform-injected variables are neither.
On RunxBuild, a Node service takes environment variables in the dashboard, and they are injected into the process at start. Changing a value is an edit and a restart rather than a rebuild, and the deploy log and runtime log sit in the same place, so a service that fails to boot on a missing variable tells you which one on the first line.
Two habits that pay for themselves. Validate at startup, as in the config module above, so a bad deploy fails immediately rather than on the first request that touches the missing value. And never log the environment object wholesale on error - dumping it in a catch block is how a session secret ends up in a log aggregator.
How this fits the rest of the stack
An Express service is a process, a port, and a set of environment variables, and only one of those should live in your repository. Push the repo, set the variables in the dashboard, and read the deploy log and the runtime log in the same place. The RunxBuild hosting calculator shows the Node service and the managed Postgres it talks to as separate line items, so the API’s real monthly cost is a number rather than an estimate.
Useful related references:
- Mongo Express: The Web Admin for MongoDB That Shouldn’t Run in Production
- Express vs Node: Express Is a Framework, Node Is a Runtime, and the Difference Is the One Most Blog Posts Skip
- Node.js Express vs Fastify vs Koa vs Hapi: The Performance, the Plugin Story, the Async Story, and the Right Choice for 2026
- Services on RunxBuild
FAQ
Why is process.env undefined in my Express app?
Almost always because the dotenv config call ran after the module that reads the variable. In CommonJS, put the config call on the first line of your entry file. In ESM, imports are hoisted and evaluated before the module body, so use the side-effect import form or preload with Node’s native env-file flag.
Do I still need dotenv on modern Node?
Not necessarily. Node 20.6 and later support a native env-file flag, which covers the common case with no dependency. Keep dotenv if you need multiple files with override precedence, variable expansion, or support for older Node versions.
Where should the dotenv config call go?
Once, as early as possible, in the entry file only - never in library modules. Calling it in multiple places is redundant at best, and in ESM it will not help because the import ordering is what matters. The preload form removes the question entirely.
Should I commit the env file for an Express app?
No. A backend env file almost always contains a real database URL or API key. Gitignore it and commit an example file with the variable names and blank values, so a new developer can copy it and fill in their own.
How do I use different env files per environment?
Call the dotenv config function multiple times with explicit paths and the override flag on the later calls - plain dotenv does not implement the precedence chain automatically. Without override, a variable already set is never replaced, so the second call silently does nothing.