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.
--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:
-
Confirm the resolved mode.
vite build --mode stagingloads.env.staging;vite buildalone loads.env.production. A filename that does not match the mode string (case-sensitive) is simply never read — there is no warning. -
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' -
Check for a per-key override. Precedence is evaluated per key, not per file. If
.env.localdefinesVITE_API_URL, it outranks.env.stagingand your staging build ships the local value. Remove the key from.env.localor move it into the mode file. -
Confirm the prefix. A key without
VITE_(or your configuredenvPrefix) never reachesimport.meta.env, so it reads asundefinedno 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.
Related
- Environment Variables and Build Modes in Vite — the precedence chain, the
VITE_gate, and mode resolution in full. - Fixing import.meta.env undefined in production builds — when the right file loads but the value is still missing.
- Advanced Vite Plugin Configuration — reading env in
configResolvedinstead of config.