Managing multiple .env files across Vite environments

Modern frontend pipelines require strict environment segregation. When orchestrating CI/CD workflows, developers frequently encounter silent variable overrides or missing VITE_ prefixed keys. This guide isolates the exact resolution chain Vite uses and provides a deterministic configuration pattern for multi-environment setups. Understanding how Vite Configuration & Ecosystem handles dotenv resolution is critical before implementing custom overrides.

Root-Cause Analysis: Vite’s dotenv Precedence Chain

Vite relies on dotenv-expand to merge .env, .env.local, .env.[mode], and .env.[mode].local. The default behavior prioritizes .env.local unconditionally, which breaks isolated staging and production builds. When loadEnv() is invoked without explicit envDir parameters, the resolver falls back to process.cwd(), causing namespace collisions across pipeline runners.

This precedence inversion triggers predictable build-time and runtime failures:

  • TypeError: Cannot read properties of undefined (reading 'VITE_API_URL') during static analysis.
  • Warning: Duplicate environment variable detected in .env.production and .env.local

Reproducible Configuration: Strict Mode Isolation

To enforce environment boundaries, override the default loadEnv() behavior by explicitly defining envDir and filtering namespaces. The following pattern guarantees that CI/CD runners ignore local overrides while preserving local development convenience. For deeper context on mode resolution, refer to Environment Variables and Build Modes in Vite.

import { defineConfig, loadEnv } from 'vite';

export default defineConfig(({ mode }) => {
 const isCI = process.env.CI === 'true';
 const envDir = isCI ? './.env-configs' : process.cwd();
 const rawEnv = loadEnv(mode, envDir, '');
 
 // Strict VITE_ prefix enforcement
 const safeEnv = Object.fromEntries(
 Object.entries(rawEnv).filter(([k]) => k.startsWith('VITE_'))
 );

 return {
 define: { __APP_ENV__: JSON.stringify(safeEnv) },
 envDir: envDir
 };
});

Implementation Steps:

  1. Create an isolated .env-configs/ directory containing .env.dev, .env.staging, and .env.prod.
  2. Set envDir dynamically based on process.env.CI to route resolution paths away from developer workstations.
  3. Apply strict VITE_ prefix filtering to prevent leaky abstractions into the client bundle.
  4. Inject sanitized variables via define for deterministic Rollup/esbuild static replacement.

Validation & Debugging: Verifying Bundle Injection

After implementing the strict loader, verify that Rollup correctly replaces placeholders during the build phase. Execute vite build --mode staging and audit the compiled output. Use grep to confirm that only mode-specific variables persist in the minified assets. If variables remain as process.env.VITE_*, the define mapping failed due to incorrect stringification.

Common diagnostic outputs:

  • ReferenceError: process is not defined (browser runtime)
  • Error: Rollup failed to resolve import 'process.env'
vite build --mode staging && grep -r 'VITE_' dist/assets/ | head -n 3

Resolution Steps:

  1. Ensure JSON.stringify() wraps all injected values in the define block to prevent raw object injection.
  2. Verify the --mode flag passed to the CLI matches the target .env.[mode] filename exactly (case-sensitive).
  3. Clear the node_modules/.vite cache directory to force fresh dotenv resolution and eliminate stale environment snapshots.