Nub reads your .env* files and injects them into the environment before Node starts — no dotenv import, no --env-file flag. Loading happens from the nearest directory with a package.json (walking up from your cwd), matching Vite's single-directory model. Works on every Node version Nub supports (18.19+).

nub server.ts   # .env* in the project root are loaded automatically

File precedence

Four filenames are loaded, highest priority first. The shell environment always wins over all of them — a value already set in the process environment is never overridden.

  1. .env.[mode].local
  2. .env.local
  3. .env.[mode]
  4. .env

The [mode] slots only exist when a mode is set; the example below resolves them under production. Among the .env* files, the first one to define a key wins (first-writer-wins); the shell env sits above all of them.

# reads .env.production.local, .env.local, .env.production, .env
APP_ENV=production nub server.ts

Selecting the mode

The mode fills the [mode] slots above. APP_ENV is the primary selector: set it to a non-empty value, and the matching .env.[mode] files load. When APP_ENV is unset, NODE_ENV acts as a fallback — but only for the three canonical values development, production, and test.

APP_ENV=production nub server.ts   # reads .env.production*
APP_ENV=staging nub server.ts      # reads .env.staging*
NODE_ENV=production nub server.ts  # APP_ENV unset → reads .env.production*

The APP_ENV variable is a framework-neutral selector: it drives which .env files load without also flipping NODE_ENV, which many tools read to switch between development and production behavior. It accepts any mode name, and it wins over NODE_ENV when both are set. To load a specific file rather than a mode, name it with --env-file (below).

A mode is only used to build filenames when it contains just letters, digits, _, ., and -. A value with a path separator (a stray APP_ENV=../other) is ignored — the [mode] files are skipped, .env and .env.local still load, and no error is raised.

NODE_ENV

When APP_ENV is unset, NODE_ENV selects the mode — but Nub clamps it to development, production, or test, matching Next.js and Bun. Those three values select the corresponding .env.[mode] files; any other value (a NODE_ENV=staging) is ignored for file selection, and only .env and .env.local load. For an arbitrary mode name, use APP_ENV.

NODE_ENV=production nub server.ts  # reads .env.production*
NODE_ENV=staging nub server.ts     # not canonical → reads only .env, .env.local
APP_ENV=staging nub server.ts      # use APP_ENV for arbitrary modes

The clamp exists because NODE_ENV is overloaded: many tools read it to switch between development and production behavior, so an unrecognized value silently flips them into development mode. Clamping the file selection to the three canonical values gives migration parity with Next.js and Bun without reintroducing that footgun.

Nub never sets NODE_ENV, and a .env file cannot change it. A .env file that assigns NODE_ENV has that one key ignored on load — matching dotenv, Next.js, and Vite — and Nub warns. Otherwise a .env pinning NODE_ENV=development leaks into production tooling: next build, for one, then runs its prerender workers in development mode against production-compiled output.

Test environment

When the mode is test — from APP_ENV=test or NODE_ENV=test — the .env.local slot is skipped, so only .env.test.local, .env.test, and .env load. This keeps developer-machine secrets in .env.local out of the test environment.

Variable expansion

Values support ${VAR} and $VAR references. References resolve against the other loaded values first, then the shell environment; an undefined reference resolves to the empty string. Expansion is multi-pass, so a value can reference another value that itself references a third.

# .env
HOST=localhost
PORT=5432
# both forms work; $HOST is equivalent to ${HOST}
DATABASE_URL=postgres://${HOST}:${PORT}/app

Escape a literal dollar sign with \$. Watch the classic footgun: a value like PASSWORD=foo$bar truncates to foo when bar is unset, since $bar expands to the empty string — quote and escape it as PASSWORD="foo\$bar".

Explicit files

Passing --env-file=<path> disables the automatic .env* discovery entirely — only the named file loads. Nub reads it through the same parser and the same ${VAR} expansion as the automatic files, and the shell environment still wins over it. This matches Bun: ask for a file by name and Nub stops guessing which files you meant.

# only .env.ci loads; auto .env* discovery is skipped; shell env still wins
nub --env-file=.env.ci server.ts

Pass --env-file more than once to load several files, in order. A later file overrides a key set by an earlier one — matching Node — and the shell environment still wins over all of them.

# both load; .env.production wins any key it shares with .env
nub --env-file=.env --env-file=.env.production server.ts

A missing file is an error. To load a file only when it is present and skip silently otherwise, use --env-file-if-exists — the Node v22 variant. It behaves identically to --env-file in every other respect.

# loads .env.local if present; no error if it isn't
nub --env-file-if-exists=.env.local server.ts

Loading nothing

Pass --no-env-file to load zero env files: the automatic .env* discovery is suppressed and any --env-file or --env-file-if-exists is ignored. Everything else Nub does — TypeScript, JSX, the module hooks — stays on. Reach for it when the environment is already managed elsewhere (CI, a secret manager, direnv) and you want Nub to keep its hands off.

# no .env* auto-discovery, and the --env-file is ignored — the child sees neither
nub --no-env-file --env-file=.env.ci server.ts

It applies on every surface — a file run, nub run, nubx, and nub watch (where no .env* file is handed to the watched Node). For a persistent, whole-tree opt-out that also disables the rest of Nub's augmentation, use --node or NODE_COMPAT=1 instead.