Environment files
Automatic environment-file loading — the file set, mode selection from APP_ENV or a clamped NODE_ENV fallback, precedence, variable expansion, the skip under the test environment, loading explicit files with --env-file, and disabling all loading with --no-env-file.
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 automaticallyFile 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.
.env.[mode].local.env.local.env.[mode].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.tsSelecting 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 modesThe 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}/appEscape 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.tsPass --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.tsA 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.tsLoading 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.tsIt 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.
Module resolution
The TypeScript-aware resolution Nub layers on top of Node — tsconfig path aliases and base URL, extends chains, extensionless imports, and the JavaScript-to-TypeScript extension swap.
Loaders
Import JSON, JSONC, JSON5, TOML, YAML, and plain-text files as default exports, plus any file as raw text via an import attribute.