Managing Multiple .env Files Across Vite Environments

When a project carries .env, .env.local, .env.staging, and .env.production at once, the wrong value silently wins and a staging build ships a production URL. This guide pins down Vite’s exact precedence chain and gives a reproducible layout for multi-environment work; for the underlying model of mode resolution and the VITE_ gate, read Environment Variables and Build Modes in Vite first.

The problem exists because Vite does not model an environment as a single file. It models it as a mode stringdevelopment, production, staging, or any custom name — and then merges up to four files whose names are derived from that string. Every file is optional, none of them warn when absent, and precedence is resolved key by key rather than file by file. That combination is what makes the failures quiet: nothing errors when a mode file is missing, and nothing flags when a git-ignored file quietly outranks a committed one. The value that reaches import.meta.env is the survivor of a merge you never see printed.

This matters most at the exact boundary where env resolution meets the bundler. Vite reads and merges the files during config evaluation, before Rollup starts, and the surviving VITE_-prefixed values are frozen into import.meta.env and later inlined as string literals during transform. There is no runtime lookup in the shipped bundle — the value is baked in. So a precedence mistake is not a misconfiguration you can patch by restarting the server against a corrected file; it is a wrong literal compiled into dist/ that ships until you rebuild. Getting the file layout right is therefore a build-correctness concern, not a convenience.

Vite .env file merge order for a staging build Four env files merge in priority order for mode staging; the highest file that defines a key wins, producing the final resolved value. mode = staging — merge order, highest priority first .env.staging.local git-ignored, machine override .env.staging committed, mode-specific .env.local git-ignored, all modes but test .env committed defaults key conflict — top file wins Resolved values VITE_API_URL = staging (from .env.staging) VITE_LOG = debug (from .env.local) VITE_APP = web (from .env) DB_PASSWORD — kept, not VITE_, never shipped .env.local outranks .env.staging only when both define the same key — order is per-key, not per-file. In mode=test, .env.local is skipped so suites do not inherit a developer's overrides.
Figure: per-key merge for --mode staging; the highest-priority file that defines a key supplies its value.

Problem scope

You have more than one .env file and need to know, deterministically, which value reaches the bundle for a given --mode. The failure looks like a leak (a local override winning in CI) or a gap (a mode file that never loads because the mode name does not match the filename).

Both shapes share one property that makes them expensive: they are invisible at build time and only surface at runtime, usually in the wrong environment. A leak passes every local check because the developer’s machine has the file that is winning; it only breaks on a runner that carries a stray copy, or in reverse, only breaks in production because the developer never had the file that should have won. A gap is worse to diagnose because the build succeeds with a plausible-looking value — the .env default — so nothing looks wrong until someone notices staging is talking to localhost. Neither failure produces a stack trace, a warning, or a non-zero exit code. The whole point of a deterministic layout is to convert these silent outcomes into something you can assert against in CI.

The two failure shapes: leak and gap A leak is a higher-priority local file winning a key it should not, so the wrong value ships; a gap is a mode file that never loads because its filename does not match the mode string, so the default value ships. Leak a higher-priority file wins a key e.g. a stray .env.local on a runner → wrong value ships Gap a mode file never loads filename ≠ mode string (case-sensitive) → silent fallback to .env default
Figure: both failures are silent — one overrides, one is skipped, and neither warns.

How the merge works under the hood

Vite builds the candidate file list from the mode string in a fixed order and then reads them from lowest priority to highest, letting each later file overwrite keys already seen. For a given mode the list is .env, .env.local, .env.[mode], .env.[mode].local, and the effective precedence is the reverse of that reading order: the mode-specific local file wins, then the mode file, then the generic local file, then the generic default. Internally this is a plain object being mutated key by key, which is exactly why precedence is per-key and not per-file — a file only “wins” for the keys it actually defines, and contributes nothing for the keys it omits.

The parse step uses dotenv to turn each file into key/value pairs and dotenv-expand to resolve ${VAR} references, so a value can interpolate another key that a lower-priority file defined. After the merge, Vite splits the result: every key is kept in the Node-side object that loadEnv returns and that config code can read, but only keys matching envPrefix (default VITE_) are copied into the client-facing import.meta.env. That two-stage split is the mechanism behind the recurring “I set it but it’s undefined” report — the key won its merge correctly and simply never crossed the prefix gate into client code.

None of this happens lazily. The full merge runs once during config resolution, the client subset is computed at that moment, and the transform pass later replaces import.meta.env.VITE_X occurrences with the frozen literal. Because the replacement is a static text substitution keyed on the literal member-expression, a dynamic access like import.meta.env[name] has nothing to substitute and survives into the output — a fact the verification step below leans on.

Prerequisites and reproducible setup

# Vite 5.x / 6.x, Node 20+
npm create vite@latest env-demo -- --template vanilla-ts
cd env-demo && npm install

# Create the four-file layout for a 'staging' mode
printf 'VITE_API_URL=http://localhost:3000\nVITE_APP=web\n' > .env
printf 'VITE_API_URL=https://staging.example.com\n'        > .env.staging
printf 'VITE_LOG=debug\n'                                  > .env.local

.env.local is git-ignored by the Vite scaffold; .env and .env.staging are committed. This split is deliberate: the two committed files encode the shared truth about each environment and travel with the repository, while .env.local holds whatever a single machine needs to override and never leaves it. The whole precedence question only becomes observable when at least one key is defined in more than one of these files, which is why the repro puts VITE_API_URL in both .env and .env.staging. Read a value in src/main.ts:

// src/main.ts — Vite 5.x / 6.x
console.log(import.meta.env.VITE_API_URL, import.meta.env.VITE_LOG)
The three-file repro layout The repro commits .env with defaults and .env.staging with the staging URL, and git-ignores .env.local carrying a debug log flag, so precedence can be observed for mode staging. .envcommitted defaultsVITE_API_URL, VITE_APP .env.stagingcommitted, mode-specificVITE_API_URL override .env.localgit-ignoredVITE_LOG=debug
Figure: two committed files and one git-ignored file — enough to make per-key precedence observable.

Diagnosis workflow

Work top-down through the precedence chain rather than guessing:

Four-step precedence diagnosis Confirm the resolved mode matches the filename, trace which env files Vite reads with the debug flag, check for a per-key override from a higher file, then confirm the key carries the VITE_ prefix. 1 · confirm modefilename match 2 · trace loads--debug grep env 3 · per-key rankhigher file wins? 4 · prefixVITE_ present?
Figure: precedence is per-key, so step 3 is where most "wrong value" cases are actually found.
  1. Confirm the resolved mode. vite build --mode staging loads .env.staging; vite build alone loads .env.production. A filename that does not match the mode string (case-sensitive) is simply never read — there is no warning. The single most common cause of a “gap” is assuming build defaults to development: it does not, the default mode for build is production and for dev/serve is development. Confirm the mode you think you passed is the mode Vite resolved by logging it from config: defineConfig(({ mode }) => { console.log('resolved mode', mode); return {} }). If that line prints production while you expected staging, the flag never reached Vite and no .env.staging was ever a candidate.

  2. Trace file loading. Run with the debug flag and watch which files Vite reads:

    npx vite build --mode staging --debug 2>&1 | grep -i 'env'

    The debug output lists each candidate path Vite attempted. A file that is present on disk but absent from this list is being looked for under a different name or a different envDir than you assume — treat its absence here as authoritative, not the fact that you can ls it. This step distinguishes a genuine gap (the file was never a candidate) from a precedence problem (the file loaded but lost).

  3. Check for a per-key override. Precedence is evaluated per key, not per file. If .env.local defines VITE_API_URL, it outranks .env.staging and your staging build ships the local value. Remove the key from .env.local or move it into the mode file. This is where the majority of “the right file loaded but the wrong value shipped” cases resolve, because the losing file is loaded and visible in step 2, which misleads you into trusting it. The fix is not to reorder anything — the order is fixed and correct — but to stop defining the same key in two files where the higher one is not the one you mean.

  4. Confirm the prefix. A key without VITE_ (or your configured envPrefix) never reaches import.meta.env, so it reads as undefined no matter which file holds it. Verify by reading the same key two ways: loadEnv(mode, envDir, '') in config will show it (config sees everything), while import.meta.env in src/ will not (client sees only the prefixed subset). A key that is visible in the first and undefined in the second is a prefix problem, full stop — the file layout is irrelevant to it.

The configuration: explicit, mode-isolated loading

The default precedence is correct for most apps, and you should resist the urge to “fix” it — reordering precedence is almost always solving the wrong problem, since the failures come from files existing in the wrong place, not from the order being wrong. Override the location Vite reads from only in two situations: when CI must ignore developer-local files, or when a monorepo keeps env files outside the package root. Both cases are handled with envDir and loadEnv, and neither touches precedence — they only change which directory the fixed four-file chain is resolved against:

// vite.config.ts — Vite 5.x / 6.x
import { defineConfig, loadEnv } from 'vite'
import path from 'node:path'

export default defineConfig(({ mode }) => {
  // In CI, read env files from a dedicated, committed directory so a stray
  // local .env on a runner cannot win. Locally, use the package root.
  const isCI = process.env.CI === 'true'
  const envDir = isCI
    ? path.resolve(__dirname, './env')   // ./env/.env.staging, etc.
    : process.cwd()

  // '' as the prefix arg returns ALL keys (including non-VITE_) so config
  // code can read server-only values; client code still only sees VITE_*.
  const env = loadEnv(mode, envDir, '')

  return {
    envDir,
    define: {
      // Inject a non-VITE_ value explicitly; JSON.stringify is required or
      // Rollup splices a bare identifier and throws.
      __DEPLOY_TARGET__: JSON.stringify(env.DEPLOY_TARGET ?? 'unknown'),
    },
    server: {
      proxy: { '/api': env.API_TARGET ?? 'http://localhost:3000' },
    },
  }
})

For a monorepo where several packages share one set of files, point envDir at the workspace root. Vite then resolves the four-file chain in that directory instead of the package folder. Note that envDir moves the whole chain, not individual files: you cannot keep .env at the workspace root and .env.staging in the package: both must live under the same envDir. If different packages genuinely need different values for the same key, give each its own envDir rather than trying to layer roots, because Vite does not walk parent directories the way Node’s dotenv conventions sometimes imply — there is exactly one directory, and it reads the four candidates from that one place. Reading finalized env from inside a plugin instead of config belongs in the configResolved hook, covered in Advanced Vite Plugin Configuration.

One subtlety in the config above deserves emphasis: the empty-string prefix passed to loadEnv is what lets env.DEPLOY_TARGET and env.API_TARGET — neither of which carries VITE_ — be read at all. Config code runs in Node, so reading a database URL or a proxy target there is safe; the danger is only in accidentally forwarding one of those values into client code. That forwarding happens exclusively through define or by prefixing the key with VITE_, so as long as you inject non-prefixed values deliberately and JSON.stringify them, the server/client boundary stays intact. The define entry for __DEPLOY_TARGET__ is the sanctioned escape hatch: it puts a specific server value into the client bundle on purpose, under a name that grep can find, rather than by a blanket prefix that would ship every match.

envDir switches by CI versus local When CI is true the config reads env files from a dedicated committed ./env directory so a stray local file cannot win; locally it reads from the package root. process.env.CI === 'true'? envDir = ./env (committed) envDir = process.cwd() CI local stray .env.local on a runner cannot win in CI
Figure: the CI branch pins env resolution to a committed directory, closing the leak path entirely.

Verification

Build each mode and prove which value landed in the output:

Four assertions on the staging build The staging URL must be present in dist, the local default must be absent, no raw import.meta.env references may remain, and no non-VITE_ key names may have leaked. grep staging URL in dist expect: match grep localhost:3000 default expect: no match grep -c import.meta.env expect: 0 grep DB_PASSWORD | API_TARGET expect: no output
Figure: one presence check and three absence checks pin down exactly which file won.
# Build staging and confirm the staging URL was inlined, not the local default
npx vite build --mode staging
grep -ro 'https://staging.example.com' dist/assets/*.js | head -n1   # match = correct
grep -ro 'http://localhost:3000'       dist/assets/*.js | head -n1   # no match expected

# Prove no env key NAMES leaked and every reference was statically replaced
grep -rc 'import.meta.env' dist/assets/*.js                          # expect 0
grep -ro 'DB_PASSWORD\|API_TARGET' dist/assets/*.js                  # expect no output

A surviving import.meta.env reference almost always means a dynamic access (import.meta.env[key]) that Vite cannot statically resolve. A leaked non-VITE_ name means you injected it through define without intending to.

The reason these four greps are trustworthy is that the values they look for are already frozen into the output. There is no environment for the shipped bundle to read at runtime, so whatever grep finds in dist/assets is exactly what a browser will execute — the check has no false negatives from lazy resolution. The one presence check proves the intended file won its key; the three absence checks each rule out a distinct failure mode: the default file winning, a dynamic access defeating static replacement, and a server-only name crossing the boundary. Run all four, not just the first — a build that passes the presence check can still fail an absence check, and that combination is precisely a leak.

Wire the same four assertions into CI so a regression fails the pipeline instead of shipping. A minimal gate is a shell step that exits non-zero the moment any expectation is violated:

# ci/verify-env.sh — run after `vite build --mode staging`. Vite 5.x / 6.x
set -euo pipefail

# Presence: the staging URL must be inlined.
grep -rq 'https://staging.example.com' dist/assets/*.js \
  || { echo 'FAIL: staging URL missing from bundle'; exit 1; }

# Absence: the local default must not have won.
if grep -rq 'http://localhost:3000' dist/assets/*.js; then
  echo 'FAIL: localhost default leaked into staging build'; exit 1
fi

# Absence: no unresolved dynamic env access survived.
if grep -rq 'import.meta.env' dist/assets/*.js; then
  echo 'FAIL: unresolved import.meta.env reference'; exit 1
fi

# Absence: no server-only key names leaked.
if grep -rqE 'DB_PASSWORD|API_TARGET' dist/assets/*.js; then
  echo 'FAIL: server-only key leaked into client bundle'; exit 1
fi

echo 'OK: staging env verified'

Because the values are literals in the output, this script is deterministic and independent of the CI runner’s own environment — it inspects the artifact, not the process env, so a stray variable on the runner cannot make it pass or fail spuriously. Pair it with the envDir isolation branch and the leak path is closed on both ends: the build cannot read a stray local file, and the gate would catch it if it somehow did.

Gotchas and edge cases

Four multi-file env edge cases A leftover .env.local wins in CI; the mode name must match the filename case-sensitively; .env.local is dropped in test mode; and editing an env file mid-session does nothing until restart. .env.local wins in CI a leftover local file outranks the mode file — isolate with envDir or delete it. case-sensitive filename --mode Staging looks for .env.Staging; no warning, silent .env fallback. .env.local dropped in test Vitest sets mode=test; put test values in .env or .env.test, not .env.local. edits need a restart env is read at server start; there is no hot reload for .env — restart or --force.
Figure: two CI/naming footguns on top, two lifecycle surprises below.

.env.local wins in CI

Symptom: a staging or production build occasionally ships a value nobody committed, and only from certain runners. Root cause: .env.local outranks .env.staging for any shared key, so a leftover .env.local on the runner — cached from a previous job, restored by an over-broad cache key, or committed by accident to a private fork — silently overrides the committed mode file. Because the file is git-ignored, it is invisible in review and easy to forget. Fix: use the envDir isolation pattern above so CI reads only from a committed directory, or explicitly rm -f .env.local in the checkout step. Confirm: run the verification greps; a leaked local value fails the absence check on the default URL.

Mode name must match the filename exactly

Symptom: the mode file appears to be ignored and the .env default ships instead. Root cause: --mode Staging looks for .env.Staging, not .env.staging. The match is case-sensitive and there is no fallback warning — the file is just skipped and you get the .env default. The same trap catches trailing whitespace in a shell variable (--mode "$ENV ") and environment-specific capitalization in CI variable definitions. Fix: standardize on lowercase mode names and reference them from a single constant in your scripts. Confirm: the --debug trace in step 2 lists the exact filename Vite looked for; compare it character-for-character against the file on disk.

.env.local is dropped in test mode

Symptom: a value present in .env.local reads as undefined under Vitest but works in dev. Root cause: when mode === 'test' (Vitest sets this), Vite intentionally skips .env.local so suites do not inherit a developer’s machine overrides — this keeps test runs reproducible across machines and CI. Fix: a value you rely on in tests must live in .env or .env.test, not .env.local. Confirm: move the key to .env.test, rerun the suite, and check that import.meta.env now carries it; if it still does not, verify the key is VITE_-prefixed, since the prefix gate applies in test mode too.

Editing an env file mid-session does nothing

Symptom: you change a value, save, and the running app keeps serving the old one. Root cause: Vite loads and merges env files once at server start; there is no watcher on .env and no hot reload for it, because the merged result is frozen into the config object the running server holds. Fix: restart the dev server, or run with --force to also clear the pre-bundled dependency cache in case a dependency captured the old value. Confirm: after restart, the console log from src/main.ts prints the new value; if it does not, you edited a file that lost its merge, not the one that wins. This is the --force behavior discussed in Optimizing Vite Dev Server and HMR.

When not to reach for envDir

The envDir override earns its keep only when file location is the problem — a monorepo with shared files, or a CI runner you cannot trust to be clean. If your failure is a wrong value in a single-package repo where every file sits at the root, envDir changes nothing and adds a conditional that future readers must reason about. Prefer the smallest fix that matches the failure: a per-key precedence problem is solved by not defining the key twice, a prefix problem by adding VITE_, and a mode-name problem by fixing the flag. Reach for envDir and loadEnv only after step 2 of the diagnosis shows the file is being read from a directory you did not intend, or when CI isolation is an explicit requirement rather than a guess.