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

Using 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.

  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. 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 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.

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.

.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

Read 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.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.

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.

nub.jsonc
{
  // ...
  "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 win

A 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.