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 — so the automatic .env* discovery on this page does not run. Naming files explicitly still works, and doing so takes Varlock out of the chain.
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 in the automatic .env* files 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.
Files named in nub.jsonc envFile expand the same way as the automatic set. Files named with --env-file are the exception — they are never expanded, so the same file expands when named in envFile and arrives verbatim when named on the flag. See explicit files.
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 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.tsValues from these files arrive verbatim — no ${VAR} expansion, matching Node's --env-file. A password holding a literal dollar sign survives intact, so PASSWORD=foo$bar stays foo$bar and needs no escaping. The \$ escape from the expansion section does not apply here either: PASSWORD="foo\$bar" keeps its backslash, as it does on Node.
# arrives as the literal string, dollar sign and all
PASSWORD=foo$barPass --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 takes precedence over Varlock. A .env.schema is a signal Nub infers ownership from; --env-file names the file outright, so Nub loads it and Varlock stays out of the chain.
# loads .env.ci; the .env.schema hand-over does not happen
nub --env-file=.env.ci server.tsLoading nothing
Pass --no-env-file to load zero env files: the automatic .env* discovery is suppressed, any --env-file or --env-file-if-exists is ignored, and a Varlock hand-over does not happen either. 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.