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.

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 — so the automatic .env* discovery on this page does not run. Naming a file explicitly 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.

  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.

# 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: .env.production*

The APP_ENV variable is a framework-neutral selector: it chooses which .env files load without changing 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: .env, .env.local only
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 puts them in 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 that sets NODE_ENV=development reaches production tooling: next build 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.

.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 \$ — 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.

# only .env.ci loads
nub --env-file=.env.ci server.ts

Values from these files arrive verbatim — no ${VAR} expansion, matching Node's --env-file. The \$ escape from the expansion section does not apply either: PASSWORD="foo\$bar" keeps its backslash.

.env.ci
# arrives as the literal string, dollar sign and all
PASSWORD=foo$bar

Pass --env-file more than once to load several files, in order.

# .env.production wins the keys 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.

# 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 takes precedence over Varlock.

# .env.ci loads; no Varlock hand-over
nub --env-file=.env.ci server.ts

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

# neither .env* discovery nor the named --env-file loads
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).

Project configuration

The same settings go in a nub.jsonc at the project root and apply to every run.

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"]). 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.