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.

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

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

Diagnosis workflow

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

  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.

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

  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.

The configuration: explicit, mode-isolated loading

The default precedence is correct for most apps. Override it only 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:

// 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. Reading finalized env from inside a plugin instead of config belongs in the configResolved hook, covered in Advanced Vite Plugin Configuration.

Verification

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

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

Gotchas and edge cases

.env.local wins in CI

.env.local outranks .env.staging for any shared key. If a runner has a leftover .env.local, it silently overrides the committed mode file. Use the envDir isolation pattern above, or delete .env.local in the CI checkout step.

Mode name must match the filename exactly

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

.env.local is dropped in test mode

When mode === 'test' (Vitest sets this), Vite intentionally skips .env.local so suites do not inherit a developer’s machine overrides. A value you rely on in tests must live in .env or .env.test, not .env.local.

Editing an env file mid-session does nothing

Vite loads env files at server start. Edits during a running dev server are ignored until restart; there is no hot reload for .env. Restart, or run with --force to also clear the dependency cache, as discussed in Optimizing Vite Dev Server and HMR.