Node.js --env-file: Load .env Without dotenv

Node.js reads .env files on its own now, so you can load one without dotenv. Start the process with node --env-file=.env server.js, or call process.loadEnvFile() from code. Both put the values on process.env. Both stopped being experimental in Node 24.10.0 and 22.21.0, so current releases of both supported LTS lines treat them as stable.

That covers most of what people install dotenv for. Migrations go wrong in the remainder: which value wins when the file and the shell disagree, and which bits of dotenv syntax Node quietly ignores.

How do I load a .env file in Node without dotenv?

Node gives you a flag, a function and a parser.

The --env-file flag loads the file before your code runs. It arrived in Node 20.6.0. It is the closest match to require('dotenv/config') at the top of an entry file, except nothing in your source has to know about it:

{
  "scripts": {
    "dev": "node --env-file=.env --watch src/server.js",
    "start": "node --env-file-if-exists=.env src/server.js"
  }
}

process.loadEnvFile(path) does the same from inside the program. The path defaults to ./.env. Use it when the file location is decided at runtime, or in a script you run with a bare node script.js and do not want to wrap in a flag.

util.parseEnv(content) parses a string and returns a plain object. It touches nothing global, which makes it the one to reach for when you want to validate values before they go anywhere near process.env.

loadEnvFile and parseEnv both landed in Node 21.7.0 and were backported to 20.12.0. Node 20 reached end of life on 30 April 2026, so if you are still on it, upgrading comes first.

What happens when the file is missing?

--env-file treats a missing file as fatal. Node stops before your code runs, printing node: .env: not found. That is right for local development, where a missing file means a broken checkout. It is wrong in a container or on a platform that injects real environment variables and ships no .env at all.

That is what --env-file-if-exists is for, added in Node 22.9.0. It behaves the same but logs a line saying the file was not found and carries on. Put it in your start script and the same command works on a laptop and in production.

process.loadEnvFile() throws an ENOENT error instead, so wrap it if the file is optional:

import { loadEnvFile } from 'node:process';

try {
  loadEnvFile('.env.local');
} catch (err) {
  if (err.code !== 'ENOENT') throw err;
}

Which value wins: the environment or the file?

The real environment wins. If PORT=8080 is already set in the shell and the file says PORT=3000, process.env.PORT is '8080' under both --env-file and loadEnvFile. The file fills gaps and never overwrites what the platform set. That is the behaviour you want in production, and it matches dotenv’s default.

Several files layer the other way round. From the Node docs: “Subsequent files override pre-existing variables defined in previous files.” So list the shared file first and the local override last:

node --env-file=.env --env-file=.env.local src/server.js

dotenv’s array-of-paths option works the opposite way, where the first value set wins unless you pass override: true. If you are porting a multi-file setup, reverse the order or you will quietly load the wrong database URL.

There is no flag to make the file beat the environment. If you depended on dotenv’s override: true, parse the file yourself and assign the values:

import { readFileSync } from 'node:fs';
import { parseEnv } from 'node:util';

const parsed = parseEnv(readFileSync('.env.test', 'utf8'));
Object.assign(process.env, parsed);

What .env syntax does Node support?

The documented format is one KEY=value per line. Text after # is a comment unless it sits inside quotes. Values may be wrapped in double quotes, single quotes or backticks, and the quotes are stripped. A leading export is ignored, so a file you also source in a shell still parses. Multi-line values inside quotes have worked since 21.7.0 and 20.12.0.

Quote style changes escapes. A \n inside double quotes becomes a real newline, which is how you keep a PEM key on one line. Inside single quotes it stays as a literal backslash and n. dotenv behaves the same way, so this one ports cleanly.

Variable expansion does not port. Given this file:

HOST=localhost
PORT=3000
BASE_URL=http://${HOST}:${PORT}

Node sets BASE_URL to the literal string http://${HOST}:${PORT}. No error, no warning. If your files lean on dotenv-expand, either write the values out in full or build the derived value in code where you can see it. Vite runs dotenv-expand by default, which is why a file that works in a Vite app can break when a plain Node script reads it.

Where does —env-file not apply?

node --run does not pass the values on. The CLI docs say variables loaded with --env-file “are not applied to the command executed by --run”. Put the flag inside the script that --run calls, not on the --run command itself.

NODE_OPTIONS is a split case. Set it in a file loaded by --env-file and Node parses and applies it. Set it in a file loaded by process.loadEnvFile() and it has no effect, because the runtime has already started.

Framework dev servers are a separate system. Vite, Next.js and Astro read their own .env files with their own rules about prefixes and modes. --env-file is for Node processes you start yourself: API servers, workers, CLIs, migration scripts and test runs.

Is dotenv still worth installing?

For a Node service on 22 or 24, mostly not. Remove the dependency, remove import 'dotenv/config' from the entry file, and put --env-file-if-exists=.env in the start script. It pairs well with running TypeScript in Node without ts-node: one node command, no loader packages. The built-in test runner takes the same flag too, as in node --env-file=.env.test --test, which is one less thing to configure when you move tests to node:test.

Keep dotenv, or dotenv-expand, if you depend on variable interpolation and do not want to rewrite your files. Keep it if you need the file to override the environment and would rather not hand-roll the parseEnv version above. Keep it for code that still has to run on Node 18 or a 20.x release older than 20.12.0.

Neither tool checks the values. A typo in DATABSE_URL loads cleanly and fails later. Whichever loader you pick, validate the variables you need at startup and refuse to boot without them.

Whoooop builds and maintains Node.js services and APIs, and trimming a dependency tree like this is routine work on those projects. If yours has grown a stack of loaders and config packages, our Node.js development page covers how we work.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch