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 automaticallyUsing Varlock?
Nub has first-party support for Varlock. When a project carries a .env.schema and Varlock is installed, Nub's own environment loading switches off entirely and Varlock owns the environment — nothing on this page applies.
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.
.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. Set APP_ENV to a non-empty value and the matching .env.[mode] files load; when APP_ENV is unset, NODE_ENV acts as a fallback.
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 chooses which .env files load without also flipping NODE_ENV. 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.
The NODE_ENV fallback
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.
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, 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.
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.tsRead the full docs for --env-file on nodejs.org.
Naming a file while Varlock owns the environment is a contradiction, so Nub refuses rather than picking a winner:
$ nub --env-file=.env.ci server.ts
Error: `--env-file` conflicts with .env.schema — varlock owns the environment.
Use one or the other.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.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.
Project configuration
The same choices live in a nub.jsonc at the project root, so every run in the project makes them without a flag.
{
// ...
"envFile": [".env", ".env.local"] // later files win
}The field takes four forms:
true automatic .env* discovery (the default)
false load nothing, like --no-env-file
"varlock" hand the environment to Varlock
[".env", ".env.local"] these files, in order; later files winA path always goes in an array, including a single one — [".env.local"], never ".env.local". A bare string names a mode, and "varlock" is the only one.
Paths resolve from the directory holding the file that supplied them — see relative paths, which matters most when the file is your global config — and they expand ${VAR} and $VAR against the environment, because a config file is never shell-expanded. A path of ".env.${APP_ENV}" reads .env.staging under APP_ENV=staging.
Passing --env-file or --no-env-file on the command line overrides the field.
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.
Varlock
First-party Varlock support — a project carrying an env-spec schema turns off Nub's own environment loading and defers to Varlock, using whichever Varlock the project or your PATH provides.