~/blog

node --env-file kills the process when .env is missing

published

#node#javascript#config

TL;DR

node --env-file=.env is not a drop-in replacement for require('dotenv').config(). The parsing is close enough that you will not notice, but the failure mode is inverted: dotenv returns an error object and lets the process boot, while --env-file exits with code 9 and the message node: .env: not found. Use --env-file-if-exists (Node 22.9.0+) anywhere the file is optional — a container, a CI runner, a prod box where the values come from the real environment.

The problem

The migration looks free. You delete a dependency and a line of bootstrap code:

- import 'dotenv/config'
- node index.js
+ node --env-file=.env index.js

It works locally, because you have a .env locally. It works in CI, because you committed a .env.example and someone copies it. Then it hits the environment that never had a .env at all — the one where DATABASE_URL arrives from the orchestrator — and the process is dead before your code runs:

$ node --env-file=.env -e "1"
node: .env: not found
$ echo $?
9

Exit code 9 is Node’s “invalid argument”. There is no stack trace, no logger, no process.on('uncaughtException') — your application never started, so nothing you wrote gets a chance to report it. Under dotenv the same box booted fine, because config() never throws on a missing path:

require('dotenv').config({ path: '.nope' })
// -> { error: [Error: ENOENT ...] }  and execution continues

Why it happens

The flag is parsed by Node itself, before module loading, so the only thing it can do with a bad path is refuse to start. That is a defensible default — a silently missing config file is how you ship a service pointed at the wrong database — but it is the opposite of what the library you replaced did, and the switch is usually made by someone deleting a dependency, not by someone reviewing startup semantics.

Node ships an explicit opt-out. --env-file-if-exists (added in v22.9.0) has identical behaviour except that a missing file is a note, not a fatal:

$ node --env-file-if-exists=.nope -e "console.log('booted')"
.nope not found. Continuing without it.
booted

Both flags left experimental status in v24.10.0 and v22.21.0.

The three traps that are not Node’s fault

Once the file exists, --env-file and dotenv agree more than people expect. All three of these behave identically under Node 26 and dotenv 16, which means blaming the migration for them is wrong — they were already happening.

Written in .envValue you getWhy
PASSWORD=hunter2#1hunter2# starts a comment unless the value is quoted
QUOTED="hunter2#1"hunter2#1quotes are stripped, the # survives
URL=postgres://${HOST}/apppostgres://${HOST}/appno interpolation, in either
export TOKEN=abcabcthe export prefix is ignored

The # one is the expensive trap, because password and secret generators emit # freely and the truncation is silent — you get an authentication failure against a value that looks right in the file and is wrong in process.env. Quote every secret, unconditionally.

Variable expansion is the second surprise for anyone arriving from a framework. Vite, Next and dotenv-expand all interpolate ${VAR}; plain dotenv and --env-file do not, and they store the literal ${HOST} text without complaint. dotenv’s own docs now point at dotenvx for expansion.

What to do

Caveats

References