node --env-file kills the process when .env is missing
published
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 .env | Value you get | Why |
|---|---|---|
PASSWORD=hunter2#1 | hunter2 | # starts a comment unless the value is quoted |
QUOTED="hunter2#1" | hunter2#1 | quotes are stripped, the # survives |
URL=postgres://${HOST}/app | postgres://${HOST}/app | no interpolation, in either |
export TOKEN=abc | abc | the 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
-
Optional file →
--env-file-if-exists. Fatal-on-missing is only correct when the file genuinely must be there. -
Multiple files, later wins.
node --env-file=.env --env-file=.development.env index.js— the second overrides keys from the first. -
The real environment always wins. If a variable is already set in
process.env, the file does not overwrite it. This matches dotenv’s default (which has anoverrideoption;--env-filehas no such escape hatch).$ PORT=9999 node --env-file=.env -e "console.log(process.env.PORT)" # .env says 3000 9999 -
Quote anything with
#, spaces or newlines. Multi-line values need quotes and Node ≥ v20.12.0 / v21.7.0. -
Keep dotenv if you rely on
override, on programmatic loading, or on expansion. “One less dependency” is not worth re-learning a config loader mid-incident.
Caveats
- Everything above was checked on Node v26.2.0 and dotenv 16.6.1 on Windows; the exit code and the
not foundmessage come from Node itself and are stable across platforms, but the exact string is not part of any API contract. - A
NODE_OPTIONSline inside the env file is honoured, which surprises people who assume the file is only read after the runtime is configured.NODE_OPTIONS=--max-old-space-size=333in the file moves the heap limit for that process, though real command-line options still outrank it. - This post is about the runtime flag, not about
process.loadEnvFile(), the programmatic sibling, which throws a catchable exception instead of exiting.