Environment Files and Configuration Loading
Configuration reaches a Node process before your application code gets a turn.
The operating system starts the process with an argument vector and an environment block. Node reads both during native startup. It may read one or more .env files next. Then it creates the JavaScript process object. By the time your entry module evaluates, process.env already contains a merged view of the inherited OS environment and any variables loaded by Node's environment-file path.
Timing matters here. A setting read by V8, OpenSSL, the inspector, the module loader, or Node's option parser belongs in startup configuration. A setting read by your application belongs in application configuration. Both may be spelled as environment variables. They have different lifetimes.
node --env-file=.env.local server.jsNode reads .env.local before server.js runs. Values from the file become visible on process.env, unless the real environment already supplied the same key. The command also gives Node a chance to read NODE_OPTIONS from that file during startup, which is a different path from programmatic loading.
console.log(process.env.PORT);
console.log(process.env.NODE_ENV);Nothing fancy happens in user code. process.env is the access point, already covered in Chapter 5. The loader is the new piece. It populated the store before the module graph started.
Node v24 exposes two CLI flags and two programmatic APIs for .env files.
The --env-file=file flag loads a file during startup and fails if the file is missing.
node --env-file=.env server.jsUse that for required configuration. Missing file, failed start. Better to stop at launch than discover halfway through initialization that the database URL or port is absent.
The --env-file-if-exists=file flag loads the same format and keeps going when the file is absent.
node --env-file-if-exists=.env.local server.jsUse that for developer overrides. A local file can exist on one machine and stay absent in CI. The launch path stays the same.
process.loadEnvFile(path) loads a file after JavaScript has started.
import { loadEnvFile } from "node:process";
loadEnvFile(".env.test");That call reads the file and writes keys into process.env. Startup has already happened. A NODE_OPTIONS value loaded here lands as ordinary text in process.env.NODE_OPTIONS; V8 flags, inspector state, preloads, permission flags, and source-map mode keep their existing startup state. Startup-only variables need startup-time loading.
util.parseEnv(content) parses raw text and returns an object.
import { parseEnv } from "node:util";
const parsed = parseEnv("PORT=3000\nDEBUG=true\n");
console.log(parsed);The parser returns strings. Always strings. 3000 is "3000". true is "true". JSON-looking text is still text. Your config layer owns coercion.
const port = Number(process.env.PORT ?? 3000);
if (!Number.isInteger(port)) throw new Error("bad PORT");Put that conversion near application startup, before the server binds sockets or opens clients. Read. Coerce. Validate. Freeze the result if the rest of the app should treat config as immutable.
Node defines its own DotEnv grammar. The detail matters. The npm dotenv package popularized the file shape, but Node core has its own parser and its own documented behavior. Most common files behave the same. Some quoting details differ.
A variable declaration is a name, an equals sign, and a value.
PORT=3000
NODE_ENV=development
API_BASE_URL=https://api.localNames may contain letters, digits, and underscores. Names begin with a letter or underscore. The documented pattern is ^[a-zA-Z_]+[a-zA-Z0-9_]*$.
DATABASE_URL=postgres://localhost/app
my_var=ok
_BOOTSTRAP=1Those names fit the documented grammar. Current Node releases tolerate more during parsing because the native parser mostly slices everything before the first equals sign and skips only empty keys. A name starting with a digit, a dash, or a space sits outside the documented contract even when today's parser accepts some of those spellings. Keep names boring. Uppercase with underscores remains the least surprising convention because shells, process managers, CI systems, and deployment tools all handle it well.
Spacing around keys and values gets trimmed.
PORT = 3000
TOKEN = abc123Node stores PORT as "3000" and TOKEN as "abc123". The whitespace beside the equals sign disappears.
Quotes preserve whitespace inside the quoted value.
GREETING=" hello "GREETING contains the two leading spaces and two trailing spaces inside the quotes. Node removes the quote delimiters.
Hash characters start comments in unquoted values.
LOG_LEVEL=debug # local detail
PASSWORD_HASH="abc#123"LOG_LEVEL becomes "debug". PASSWORD_HASH keeps the hash because the character sits inside quotes.
Single quotes, double quotes, and backticks can wrap values. Double-quoted values also expand \n into actual newline characters in Node's native parser.
PRIVATE_KEY="line1\nline2\nline3"
SQL='select * from users'
RAW=`literal text`Multiline quoted values are supported. They are useful for certificates, private keys, and test fixtures. I usually prefer file paths for large secrets because logs and diagnostic reports get fewer chances to capture the whole value by accident.
export prefixes are accepted.
export PORT=3000Node drops the prefix and stores PORT. That helps when one file is also sourced by a shell. Still, shell compatibility has limits. Node's parser and a shell parser have different expansion rules. A file that uses $HOME, command substitution, or shell quoting tricks belongs to shell startup code. Keep Node's DotEnv path to plain assignments.
Duplicate keys inside one file use the later value.
PORT=3000
PORT=4000The parsed value is "4000". Node's parser stores values in a map and assigns by key as it reads. Last assignment wins inside the parsed file.
Precedence decides which writer owns the final value.
Start with the real environment:
PORT=9000 node --env-file=.env server.jsIf .env contains PORT=3000, process.env.PORT is still "9000". The inherited environment wins over file values. That rule gives the deployment platform the final say. A Kubernetes manifest, systemd unit, CI job, or shell assignment can override the repository's file without editing it.
Multiple env files layer in command order.
node --env-file=.env --env-file=.env.local server.jsValues from .env.local override values from .env for keys supplied by those files. Then the inherited environment still wins over both. The useful pattern is base defaults first, local or environment-specific values second, deployment environment last.
# .env
PORT=3000
LOG_LEVEL=info# .env.local
LOG_LEVEL=debugThe process sees PORT="3000" and LOG_LEVEL="debug" unless the real environment supplied either key. Keep the order visible in the command. Hidden loading order costs time during incident review.
process.loadEnvFile() follows a related assignment rule at runtime: parsed values fill missing keys and leave existing process.env entries alone. That is the same conservative merge style used by the startup path when it writes parsed file data into the environment store.
process.env.PORT = "9000";
loadEnvFile(".env");
console.log(process.env.PORT);If .env says PORT=3000, the output stays "9000". Existing entries win. Treat programmatic loading as a way to add defaults. Use direct assignment when you intentionally replace process state after startup.
For application code, the clean pattern usually looks like this:
const config = Object.freeze({
port: readPort("PORT"),
logLevel: readEnum("LOG_LEVEL", ["debug", "info", "warn"]),
});Everything downstream receives config. Raw process.env access stays at the process boundary. Parsing and validation sit in one place. Tests get easier too, because a test can construct a config object without mutating process-wide state.
NODE_OPTIONS is special during startup. It contains Node flags, and Node parses it before the entry module starts. Subchapter 09.01 covered the flag parser. Env files add one more source.
NODE_OPTIONS=--enable-source-maps
PORT=3000node --env-file=.env server.jsDuring startup, Node reads the file, finds NODE_OPTIONS, tokenizes the value, checks the allowlist, and applies accepted flags at the dotenv precedence tier. Source maps can turn on before user code runs. The application also sees process.env.NODE_OPTIONS as text.
Direct environment still outranks file values.
NODE_OPTIONS="--trace-warnings" \
node --env-file=.env server.jsThe real NODE_OPTIONS wins over the one in .env. Node's config hierarchy also places JSON config above dotenv-provided NODE_OPTIONS, and direct command-line flags sit above both for singleton values. The file can provide startup defaults. The deployment can override them.
There is a trap here. Loading the same file from JavaScript has different timing.
import { loadEnvFile } from "node:process";
loadEnvFile(".env");If .env contains an inspector flag inside NODE_OPTIONS, the inspector stays in its current state. Node already parsed startup options. The value exists only as process.env.NODE_OPTIONS. Keep runtime flags out of application-loaded env files for that reason. Use the --env-file flag when the file intentionally controls Node. Use loadEnvFile() when the file intentionally controls application config.
Allowed flags still matter. NODE_OPTIONS accepts a curated set of Node flags. Program replacement flags, eval flags, and help/version style flags belong on the actual command line. The allowlist is visible through process.allowedNodeEnvironmentFlags.
console.log(
process.allowedNodeEnvironmentFlags.has("--enable-source-maps"),
);That returns true on v24. For a flag-shaped string taken from config, check it before constructing a child process command or writing a generated launch file.
process.loadEnvFile() is small on purpose.
import { loadEnvFile } from "node:process";
loadEnvFile();With the default call, Node reads ./.env from the current working directory. Pass a path, file URL, or buffer when the file lives elsewhere.
import { loadEnvFile } from "node:process";
loadEnvFile(new URL("../.env.test", import.meta.url));That form removes the current-working-directory dependency for ESM. Package scripts, test runners, process managers, and IDE launchers can start from different directories. A URL relative to the module keeps the config file tied to the module location.
The function throws on missing files and filesystem read failures.
try {
loadEnvFile(".env.required");
} catch (err) {
console.error(err.message);
process.exitCode = 1;
}Keep that path early. Once application initialization has opened sockets, scheduled timers, or built clients, late config failure becomes cleanup work. Load and validate before creating durable resources.
util.parseEnv() fits tooling and tests better than direct mutation.
import { parseEnv } from "node:util";
const values = parseEnv("PORT=3000\nFEATURE_X=true\n");No process state changes. You get an object. That makes it useful for config linters, build scripts, tests, and custom layering code.
const parsed = parseEnv(text);
for (const key of Object.keys(parsed)) {
if (!schema[key]) throw new Error(`unknown ${key}`);
}That check catches stale keys. Someone removes REDIS_URL from the app but leaves it in the env file. The process still starts. A linter can fail the change before deploy.
For tests, avoid changing real process.env when a pure object will do.
const env = parseEnv("PORT=0\nLOG_LEVEL=debug\n");
const config = readConfig(env);A config reader that accepts an env object is easier to test than one that reaches into global process state. Production can pass process.env. Tests can pass a plain object.
Many Node projects already use the npm dotenv package. Node's built-in loader covers the common case: read a file, parse key-value declarations, populate process.env.
import "dotenv/config";The package preload runs as JavaScript. It runs after Node startup options have already been parsed. It can populate application values early enough for most apps, especially when loaded before the app entry. Startup decisions already made by Node keep their current state.
node --require dotenv/config server.jsThat command preloads the package before server.js. Good for application config. Startup flags still need the real command line, real environment, or Node's startup env-file path.
Node's built-in CLI path moves one part earlier.
node --env-file=.env server.jsThe file is parsed during Node startup. A NODE_OPTIONS key in that file can feed Node's option parser at the dotenv tier. That is the main runtime difference.
The npm package still has reasons to exist. It has ecosystem habits, extension packages, custom override modes, package-specific options, and compatibility with older Node versions. Some projects also use dotenv-expand style variable expansion. Those features belong to userland packages. Node core keeps its parser smaller.
Migration should be mechanical.
node --env-file=.env app.jsThen remove the package preload from the entry point.
// before
import "dotenv/config";
import "./server.js";// after
import "./server.js";Run the config tests after the change. Pay attention to quotes, duplicate keys, multiline values, and expansion. A project that relied on package-specific behavior may need to keep the package or add explicit application parsing.
One migration pattern keeps both paths during a transition.
if (process.env.LOAD_DOTENV === "1") {
await import("dotenv/config");
}Older launch commands get a compatibility switch while new commands move to the built-in env-file flag. Remove it once every launcher has moved. Unowned compatibility switches linger in startup code.
Import order decides whether programmatic loading happens early enough.
CommonJS can load env before the app by requiring a setup file.
node --require ./load-env.cjs server.cjsconst { loadEnvFile } = require("node:process");
loadEnvFile(".env");The preload runs before server.cjs. Any module loaded by the server after that sees the populated environment.
ESM has the same idea through the import preload flag.
node --import ./load-env.mjs server.mjsimport { loadEnvFile } from "node:process";
loadEnvFile(".env");The setup module evaluates before the entry module. It works for application config. The CLI env-file flag still gives clearer intent when the setup is only file loading.
Static imports inside a module run before that module's body. So this file loads config too late for server.mjs:
import "./server.mjs";
import { loadEnvFile } from "node:process";
loadEnvFile(".env");The import is evaluated before the call. The code looks ordered top to bottom, but ESM evaluation rules make the imported graph run first. Use an env-file flag, an import preload, or a separate bootstrap module with import().
import { loadEnvFile } from "node:process";
loadEnvFile(".env");
await import("./server.mjs");The bootstrap module loads env first, then imports the server with import(). It is explicit. It also makes top-level startup async, which may be fine for tools and services that already use ESM.
Preloads should stay small. A preload that reads config, opens database clients, patches globals, and starts metrics makes startup order hard to inspect. Keep env loading and config parsing near each other, then hand a typed config object to the application.
Configuration gets messy when several owners write the same key. The technical precedence rules solve the merge. Ownership stays separate.
Give each source a job.
The committed example file describes shape. It lists keys, safe defaults, and empty placeholders. The local env file describes a developer machine. The deployment environment describes the running service. The command line describes Node runtime policy. A secret manager describes sensitive values. Mixing those jobs turns a small config system into guesswork.
# .env.example
PORT=3000
DATABASE_URL=
SESSION_SECRET=That file belongs in source control. It documents the contract. It carries safe placeholders.
# .env.local
DATABASE_URL=postgres://localhost/app
SESSION_SECRET=dev-onlyThat file belongs on a developer machine. It belongs in .gitignore. The values may be low-risk development values, but treating the file as local-only builds the right habit.
Deployment config should be visible in the deployment system.
PORT=8080 DATABASE_URL="$DATABASE_URL" node dist/server.jsReal services usually spell that through a manifest. The point is ownership. Production values come from the platform. The repository can describe required keys, but the platform supplies environment-specific values.
Node runtime flags deserve their own review path.
node --report-on-fatalerror --env-file=.env dist/server.jsThe command has two kinds of data. The fatal-report flag changes Node behavior. .env supplies application-facing strings. Keeping that split visible helps reviewers see which part changes the runtime and which part changes the app.
The ugly version hides runtime behavior in a general env file.
NODE_OPTIONS=--report-on-fatalerror --enable-source-maps
PORT=8080It works under the env-file startup path. It also lets a plain key-value file change crash-report behavior and stack-trace behavior. Some teams accept that because one mounted env file is their platform contract. Others ban NODE_OPTIONS from app env files. Pick a rule and automate it.
const forbidden = ["NODE_OPTIONS", "NODE_EXTRA_CA_CERTS"];
for (const key of forbidden) {
if (Object.hasOwn(parsed, key)) throw new Error(`reserved ${key}`);
}A small linter catches accidental runtime keys before the command ever starts Node. It can run in CI against .env.example, .env.test, and any checked-in deployment templates.
Ownership also applies inside the application. Libraries should receive config from the application, rather than reading arbitrary env keys during import. A package that reads process.env at import time has already chosen a global configuration source. That makes it harder for the application to validate, document, and test config.
const db = createDatabaseClient({
url: config.databaseUrl,
});That call keeps ownership with the application. The database client receives a source-agnostic string. The origin can be a .env file, Kubernetes, systemd, a test object, or a developer shell.
Layering is useful when the layers are boring.
node --env-file=.env.defaults \
--env-file=.env.development \
src/server.jsThe first file supplies shared defaults. The second file supplies environment-specific values. A real environment variable can still override both. That three-step model is enough for most local workflows.
Keep names specific.
.env.defaults
.env.development
.env.test
.env.localThose names tell the reader the intent. A pile of .env, .env2, .env.new, and .env.prod.bak tells the reader nothing. The loader accepts any filename, so filename discipline has to come from the project.
Layering secrets with defaults needs care. Defaults can include a port, a log level, and a local feature switch. Secrets should arrive from the last owner in the chain, usually the platform or the developer-local file.
# .env.defaults
PORT=3000
LOG_LEVEL=info# .env.local
DATABASE_URL=postgres://localhost/appThe split lets a committed file stay safe. It also reduces merge conflicts because developers edit their own ignored file.
Test files have a different shape.
PORT=0
LOG_LEVEL=warn
DATABASE_URL=postgres://localhost/app_testPORT=0 is a useful test value because the OS picks an available port. The config parser still sees the string "0", so the port validator needs to accept zero for test and service code that intentionally asks the kernel for an ephemeral port.
function readListenPort(raw) {
const port = Number(raw);
if (Number.isInteger(port) && port >= 0) return port;
throw new Error("bad PORT");
}The validator differs from a public service validator that requires a positive port. Config policy belongs to the app. Node's env loader owns file syntax and string loading.
Generated env files should be treated as build artifacts. A build step that writes .env.generated can work, but it needs clear invalidation and storage rules.
node --env-file=.env.generated dist/server.jsIf the file is generated from secrets, protect it as a secret. If the file is generated from build metadata, keep it out of secret paths. Mixing both in one generated file makes cleanup and access control harder.
Watch mode adds one operational wrinkle. The supervising Node process and the restarted child process have different jobs. Env-file loading belongs to the child that runs the user program, so edits to the env file can be picked up across restarts.
node --watch --env-file=.env.local src/server.jsEdit src/server.js, and watch mode restarts the program. Edit .env.local, and behavior depends on which paths watch mode observes. The env file itself may need to be included through watch-path settings or through a wrapper that restarts when config changes. The startup rule remains: each fresh child process reads the env file before the entry module runs.
Long-running production services usually need a full process restart for env changes. Mutating a ConfigMap, systemd environment file, or secret mount leaves an already running process with its current state. Node has already copied values into its environment store. Application config has probably already copied them into a typed object.
That behavior works well operationally. A config change becomes a deploy or restart event. It has logs, rollout state, and rollback mechanics.
Live reload is a different feature. If an application needs live log-level changes or feature switches, build a live config path explicitly.
adminEvents.on("log-level", value => {
logger.setLevel(readLogLevel(value));
});The code updates one known setting through one known path. It avoids turning process.env into a mutable control plane.
Environment variables look portable until a project hits the edges.
Windows treats environment variable names case-insensitively at the operating-system level. Node exposes process.env with platform behavior underneath. Main-thread environment access on Windows can preserve a spelling while lookup behavior follows Windows rules. Worker environments can differ because they may use a copied map store.
Keep keys uppercase and unique under case folding.
API_KEY=one
api_key=twoThat pair is asking for platform-specific behavior. Use one spelling. Uppercase with underscores keeps the file readable and keeps Windows behavior predictable.
Path-like values also differ by platform.
PATH=/usr/local/bin:/usr/binThat value is Unix-shaped. Windows path lists use semicolons and drive letters. Avoid manufacturing PATH in shared env files unless the file is platform-specific. For child processes, pass the inherited PATH or build a platform-aware value.
spawn("tool", [], {
env: { ...process.env, TOOL_MODE: "ci" },
});That spreads the current environment, then overlays one value. It is convenient. It also forwards secrets. Use it for trusted child tools. Use a smaller allowlist for tools that execute third-party code or process untrusted input.
Line endings are handled by Node's parser. It removes carriage returns before parsing, so CRLF files from Windows editors parse normally. Encoding is more boring: write UTF-8. Environment variable values are strings, but downstream tools may expect byte-level formats for credentials, certificates, or paths. Test the exact file bytes when secrets contain non-ASCII text.
Schema Libraries
Hand-written parsing works for small services. A schema library starts helping when there are many keys, nested validation rules, default policies, or TypeScript types to export.
The boundary stays the same.
const raw = {
PORT: process.env.PORT,
DATABASE_URL: process.env.DATABASE_URL,
};Feed strings into the schema. Get typed values out. Keep process.env access near the boundary.
A useful schema layer reports all missing or invalid keys together. One-error-at-a-time startup wastes time, especially in CI or deployment pipelines. It should also hide secret values in error messages.
throw new Error("invalid config: PORT, DATABASE_URL");That is enough detail for a human to fix the launch configuration. Printing DATABASE_URL itself is usually a mistake.
Schemas should also decide unknown-key behavior. For application env files, rejecting unknown keys is often useful because it catches stale config. For the real process environment, rejecting unknown keys is painful because platforms inject many unrelated keys. The usual compromise is: validate known application keys from process.env, and lint project env files with a stricter unknown-key rule.
const known = new Set(["PORT", "DATABASE_URL", "LOG_LEVEL"]);
for (const key of Object.keys(parseEnv(fileText))) {
if (!known.has(key)) throw new Error(`unknown ${key}`);
}That linter targets files the repository owns. It leaves the deployment environment free to include platform variables such as HOME, PATH, HOSTNAME, and service metadata.
JSON Config Is Separate
Node v24 also has experimental JSON runtime config from subchapter 09.01. Env files and JSON config have different jobs.
Env files produce environment variables.
PORT=3000
LOG_LEVEL=infoJSON config produces Node runtime options.
{
"nodeOptions": {
"enable-source-maps": true
}
}The JSON file is for Node flags. It can describe source maps, conditions, test runner options, TypeScript stripping, permission settings, and other runtime settings Node knows how to parse. Application settings such as PORT, DATABASE_URL, and LOG_LEVEL belong in the environment or an application config file.
Using both is fine.
node --experimental-config-file=node.config.json \
--env-file=.env dist/server.jsThe launch has two clear inputs. Node reads runtime options from JSON and application-facing strings from .env. The precedence rules still apply for NODE_OPTIONS, command-line flags, and dotenv-provided values, but the intent is readable.
Avoid encoding the same decision in both places. A conditions value in JSON and a conditions flag inside .env can both participate because conditions are repeatable. Maybe that is intentional. Maybe it is accidental. Keep one owner for each runtime decision.
JSON config is experimental in v24. Env-file support is stable in current v24. The stability difference should affect where you use each feature. A production service can use the env-file flag as a stable loading mechanism. Experimental JSON config deserves a pinned Node version and upgrade tests.
The Native Path
The built-in loader starts in native code. Startup timing comes from that path, along with a few behaviors that differ from the old npm package habit.
During CLI processing, Node scans the argument list for the env-file and env-file-if-exists flag forms. The scanner stops at the argument separator, because everything after that marker belongs to the application. Each match becomes a small record: path plus an optional-file boolean. Node preserves the command order, so later files can layer over earlier files.
The parser object is node::Dotenv. Internally it owns a std::map<std::string, std::string>. Parsed keys sit in sorted map storage, and assignment by key replaces the previous value. When the parser sees the same key twice, insert_or_assign() keeps the later assignment. When util.parseEnv() returns an object, the final parsed map is converted into a JavaScript object. When the env-file startup path or loadEnvFile() writes into the process environment, the parsed map is merged into the environment store.
File reading goes through libuv filesystem calls. Dotenv::ParsePath() opens the path with uv_fs_open(), reads chunks into an 8192-byte buffer with uv_fs_read(), appends them into a std::string, closes the file through scope cleanup, and calls ParseContent() on the accumulated text. Startup env-file loading is synchronous in the native startup path. Programmatic loadEnvFile() is also synchronous. No event loop phase participates. No Promise exists. The goal is settled process configuration before user code depends on it.
ParseContent() normalizes Windows newlines by removing \r, trims leading and trailing spaces from the whole content, then walks the remaining string. Blank lines and lines starting with # are skipped. For each candidate line, it searches for the next equals sign or newline. A newline before an equals sign makes the line invalid for a declaration, so the parser skips to the next line. An equals sign produces the key slice. The key is trimmed. An export prefix gets removed from the trimmed key. Empty keys are skipped.
Value parsing depends on the first character after trimming. A double quote has a dedicated branch that extracts text until the next double quote and then replaces \n sequences with real newline bytes. The general quoted branch handles single quotes, double quotes, and backticks by taking text between the matching delimiter. Unclosed quotes fall back to taking the current line or the remaining content as the value. Unquoted values run until the newline, strip an inline # comment if present, then trim surrounding whitespace.
That parser is intentionally small. $VAR stays literal. Shell syntax stays literal. JSON-looking values stay strings. Source-location tracking for every key stays outside the parser. It reads text and produces string pairs. That restraint is good for startup code because every feature added to env-file parsing becomes part of process creation semantics.
After parsing, Dotenv::SetEnvironment() writes values through the current Environment object's env_vars() store. That store implements a small key-value interface called KVStore. The main process uses RealEnvStore, backed by libuv calls such as uv_os_getenv(), uv_os_setenv(), uv_os_unsetenv(), and uv_os_environ(). Worker environments may use a cloned map store depending on how the worker was created. So process.env is a JavaScript object with named property interceptors, but those interceptors forward get, set, delete, query, and enumerate operations into a native store.
The getter path converts the JavaScript property name into UTF-8, calls the environment store, and converts the result back into a V8 string. The setter path converts the assigned value to string, writes it through uv_os_setenv() for the real process store, and emits the special timezone notification when the key is TZ. Delete goes through uv_os_unsetenv(). Enumeration asks libuv for the environment array and hides Windows variables whose names begin with =.
That design has two practical consequences. Reads from process.env.FOO go through native-backed property lookup. Use them at startup, then copy validated values into an application config object. Also, mutating process.env is process-wide for the main environment. A module that changes process.env.TZ, NODE_TLS_REJECT_UNAUTHORIZED, or a database URL at runtime mutates shared process state, and other modules can observe it.
process.loadEnvFile() is a thin JavaScript wrapper over a native binding. The wrapper validates path-like input using internal filesystem path validation, then calls the binding. The native binding defaults to .env, converts the path to a namespaced path where needed, checks file-system read permission when the permission model is active, parses through Dotenv, and merges parsed keys into the environment store. Missing file becomes an ENOENT open error.
NODE_OPTIONS has an extra native path during startup. After an env file is parsed, Dotenv::AssignNodeOptionsIfAvailable() can copy the parsed NODE_OPTIONS value into the option parser input. The option parser then tokenizes it with Node's NODE_OPTIONS tokenizer, validates the allowlist, and applies accepted flags at the right precedence tier. Programmatic loadEnvFile() skips that startup option application because it runs after option parsing.
The whole chain is boring in the best way: scan CLI flags, read files synchronously, parse into a map, merge into an environment store, optionally feed NODE_OPTIONS into the startup option parser, create the JavaScript environment, then run the entry module.
Typed Config Belongs Above Strings
Environment variables are strings because the OS environment is strings. Treat that as the boundary.
PORT=3000
TRUST_PROXY=false
RETRY_LIMIT=5Those values look typed. Node stores three strings.
console.log(typeof process.env.PORT);
console.log(typeof process.env.TRUST_PROXY);Both print "string" when the keys exist. A config module should turn those strings into application types once.
function readBoolean(name) {
const value = process.env[name];
if (value === "true") return true;
if (value === "false") return false;
throw new Error(`${name} must be true or false`);
}That is intentionally strict. Accepting 1, yes, on, TRUE, and an empty string may feel friendly, then it creates different behavior between docs, deploy manifests, and tests. Pick one spelling. Reject the rest.
Numbers need the same treatment.
function readPort(name) {
const port = Number(process.env[name]);
if (Number.isInteger(port) && port > 0) return port;
throw new Error(`${name} must be a positive integer`);
}Ports also have an upper bound in real code, but the point is the same: parse once, fail early, pass typed config around.
Enums are even better.
function readLogLevel(value) {
if (["debug", "info", "warn", "error"].includes(value)) return value;
throw new Error("bad LOG_LEVEL");
}Downstream code should receive one of those four strings. Request handling should branch on typed config values.
Configuration modules often grow into one exported object.
export const config = Object.freeze({
port: readPort("PORT"),
trustProxy: readBoolean("TRUST_PROXY"),
logLevel: readLogLevel(process.env.LOG_LEVEL ?? "info"),
});That module can run near startup. It throws before the server starts. The frozen object makes accidental mutation noisy in tests and code review.
Secrets And Diagnostics
Env files are convenient for local secrets. They are also easy to leak.
The value ends up in process memory. It may appear in diagnostic reports unless env capture is disabled. It may appear in logs if a config dump prints process.env. It may get inherited by child processes. It may sit in shell history if passed inline. .env files also tend to spread through local machines, CI caches, Docker build contexts, and copied project folders.
Keep secrets out of committed env files.
DATABASE_URL=postgres://localhost/app
SESSION_SECRET=replace-me-locallyA checked-in example can document keys and safe local placeholders. Real values should come from a secret manager, deployment environment, CI secret store, or developer-local ignored file.
Use .env.example for shape.
PORT=3000
DATABASE_URL=
SESSION_SECRET=The empty values are useful. They show the required keys without making a fake secret look acceptable.
Diagnostic reports deserve specific attention. Subchapter 09.01 covered the report-exclude-env flag. Env files populate the same environment record as inherited variables, so report behavior sees them the same way. If a service can write diagnostic reports in production and the environment contains secrets, exclude environment capture or route reports through a storage path with the same access policy as secrets.
Child processes inherit environment by default in many Node APIs. child_process.spawn() uses process.env, and an explicit env option replaces that default. A CLI helper launched from a server can receive database passwords, API tokens, and feature flags outside its job.
spawn("node", ["worker.js"], {
env: { NODE_ENV: process.env.NODE_ENV ?? "production" },
});An explicit env object is quieter. Include only the values the child needs. Add PATH when command lookup depends on it. On Unix, omitting PATH changes lookup behavior. On Windows, environment key casing has its own rules, so build and test the exact shape used by the service.
Workers have a related boundary. Worker threads receive a copy of the parent environment by default, configurable through the worker env option.
new Worker(new URL("./worker.js", import.meta.url), {
env: { WORKER_MODE: "indexer" },
});That copy means a later parent mutation may be absent from the worker. Good. Treat worker config as constructor data. Passing everything through shared process state makes startup order harder to inspect.
Loading Strategies
A project usually needs fewer layers than people expect.
For local development, a base file plus optional local override works well.
node --env-file=.env --env-file-if-exists=.env.local src/server.jsCommit .env only when every value is safe and intentionally local. Otherwise commit .env.example, ignore .env, and let developers create their own file. Use the if-exists env-file flag for personal overrides so a missing file stays normal.
For tests, prefer explicit test files or pure parsed objects.
node --env-file=.env.test --testThat keeps test defaults visible in the test command. For unit tests of config parsing, parseEnv() is cleaner because it avoids process-wide mutation.
For production, let the platform own real values.
node --enable-source-maps dist/server.jsThe service manager, container runtime, or orchestrator supplies environment variables. Env files can still exist in some production systems, especially systemd EnvironmentFile= or platform-specific secret mounts. In those setups, be precise about ownership. A file mounted by the platform is deployment config. A file committed with the app is repository config. They deserve different review paths.
For runtime flags, keep a separate decision.
node --env-file=.app.env --max-old-space-size=2048 dist/server.jsThat command loads application values from .app.env and sets the heap cap directly on the command line. The separation is visible. The file can still contain NODE_OPTIONS, but hiding Node flags in a general app env file makes review harder. Use that only when the team has a clear rule.
The same applies to NODE_ENV. Node core mostly treats it as ordinary environment text. Frameworks and libraries often attach behavior to it. Your app may also branch on it. Define exact accepted values in your config module instead of letting every dependency infer a mode from a free-form string.
Current Working Directory
Relative env-file paths resolve against the process current working directory.
node --env-file=.env app/server.jsThat means "read .env from process.cwd()". The entry file path is irrelevant to that lookup. Package managers usually set the working directory to the package root. Process managers can set it explicitly. IDEs vary. Docker WORKDIR controls it inside containers.
Make the directory part of the launch contract.
WORKDIR /app
CMD ["node", "--env-file=.env", "dist/server.js"]With that Dockerfile, the relative env-file path resolves under /app. Without a stable working directory, the same command can read a different file or fail to find one.
Programmatic loading can avoid that dependency with import.meta.url.
loadEnvFile(new URL("../config/.env", import.meta.url));That path is tied to the current module file. Use it for tools and tests where the launcher may start from arbitrary directories.
Path handling also interacts with permissions. With the permission model enabled, process.loadEnvFile() checks file-system read permission for the path. Startup env-file loading runs during native startup and still needs normal operating-system read access to the file. The permission model gets its own subchapter, but the operational result is simple: programmatic env-file loading in a hardened process needs explicit read access.
Runtime Mutation
process.env stays mutable after startup.
process.env.LOG_LEVEL = "debug";
delete process.env.OLD_FLAG;Those operations go through native property interceptors. Set converts the assigned value to a string. Delete removes the key from the environment store. Enumeration asks the store for keys.
Use that mutability sparingly. Runtime mutation makes config time-dependent. A module that reads process.env.LOG_LEVEL during import may see one value. A request handler that reads it later may see another. A worker created before mutation may hold the old value. A child process spawned after mutation may inherit the new one.
A config object avoids that whole class of bugs.
const app = createApp(config);
server.listen(config.port);Pass config into modules that need it. For code that truly needs live reconfiguration, use an explicit state object or control plane. Treat process.env as startup input.
There are exceptions. Tests sometimes patch process.env. CLI tools sometimes set a variable for a child process and then restore it. Keep those mutations scoped.
const oldValue = process.env.TZ;
process.env.TZ = "UTC";
try {
runDateTest();
} finally {
process.env.TZ = oldValue;
}Even there, parallel tests can collide because process environment is shared across the process. Isolate tests that mutate env, or design the tested code to accept an env object.
A Small Config Module
Most services benefit from a tiny hand-written config reader. Add a schema library when the config surface grows, but the mechanics stay the same.
function requireEnv(env, key) {
const value = env[key];
if (value) return value;
throw new Error(`missing ${key}`);
}That helper treats empty string as missing. If empty string is valid for a key, write a different helper. Be explicit per key.
function readInt(env, key, fallback) {
const raw = env[key] ?? String(fallback);
const value = Number(raw);
if (Number.isInteger(value)) return value;
throw new Error(`${key} must be an integer`);
}Fallbacks belong in code or in a base env file. I prefer code for values that are genuinely defaults, such as PORT=3000 for local development. I prefer env files or platform config for values that describe deployment, such as external hostnames.
export function readConfig(env = process.env) {
return Object.freeze({
port: readInt(env, "PORT", 3000),
databaseUrl: requireEnv(env, "DATABASE_URL"),
});
}That shape keeps the global access at the edge of the module. Tests can pass a plain object. Production uses the default.
const config = readConfig({
PORT: "0",
DATABASE_URL: "postgres://localhost/test",
});No global mutation. No cleanup. No ordering problem between tests.
Use schema libraries when they improve the config boundary. They give better error messages, type inference, coercion policy, and nested config support. Keep the same boundary: env strings in, typed config out.
Common Failure Modes
The most common failure is loading too late.
import "./server.js";
loadEnvFile(".env");If server.js reads config during import, the load happens after the read. Put env loading before imports that consume config, or use startup env-file loading. ES modules make this more visible because static imports evaluate before later statements in the importing module body. A preload can also help when programmatic loading has to happen before the entry module.
node --import ./load-env.mjs server.mjsload-env.mjs can call loadEnvFile() before server.mjs evaluates. For plain env files, the CLI flag is cleaner. Use a preload when setup has logic beyond file parsing.
Another failure is assuming shell expansion.
HOME_DIR=$HOMENode stores "$HOME" as text. Shell substitution stays outside the DotEnv parser. Expand values in your application config when you mean to support expansion.
const homeDir = process.env.HOME_DIR?.replace("$HOME", process.env.HOME);That example is intentionally narrow. A full shell-expansion language inside config needs rules, tests, and security review. Most services should avoid it.
Quoting differences cause smaller bugs.
MESSAGE="hello # still value"
OTHER=hello # commentMESSAGE includes the hash text. OTHER drops the comment. Keep comments on their own lines when values may contain punctuation.
Hidden overrides are worse.
export PORT=9000
node --env-file=.env server.jsThe exported PORT wins. A developer may stare at .env and wonder why the service listens elsewhere. When debugging config, print the validated config object instead of every raw environment variable. The typed object shows the values the app will use without dumping secrets by default.
console.info({
port: config.port,
logLevel: config.logLevel,
});Leave secrets out of that object. For secret presence, log booleans or source names.
Where Env Files Fit
Env files are input files for process configuration. They are good for local defaults, tests, and platform-managed key-value mounts. Put complex typed configuration, nested documents, and large secret material in a dedicated application config format or a secret store.
When a value affects Node startup, load it with the env-file flag or the real environment before Node starts. When a value affects application startup, load it before reading config, coerce it once, and pass typed config into the rest of the app. When a value is secret, assume every diagnostic path and child process boundary can expose it unless configured otherwise.
That keeps the runtime side simple. Node reads strings. Your config layer turns them into decisions.