C.W.K.
Stream
Lesson 03 of 07 · published

Env Modes — .env Files Without Leaking Secrets

~10 min · env, vite, modes, secrets

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Every environment variable in your repo is one careless commit away from being public. Vite's env model is conservative on purpose: explicit prefix, layered files, build-time substitution.

The file hierarchy

  • .env — loaded in all modes. Commit this for non-secret defaults.
  • .env.local — loaded in all modes, gitignored. Your local overrides.
  • .env.development — loaded only in dev. Commit safe-for-dev values.
  • .env.production — loaded only in build/preview.
  • .env.[mode].local — gitignored mode-specific overrides.

Vite merges them in order; later files override earlier ones.

The VITE_ prefix

Only env vars starting with VITE_ are inlined into client code via import.meta.env.VITE_X. Everything else stays out of the bundle — even if you reference it. This is a static analysis: the prefix is your safety rail.

Build-time substitution

Vite replaces import.meta.env.VITE_X with the actual string at build time. The variable's value bakes into the JS. There's no runtime env lookup in the browser. Two consequences:

  • Changing the value requires a rebuild.
  • Secret values intended only for runtime should never use VITE_ prefix — they'd be in the JS, readable by anyone who downloads it.

What about runtime config?

If you need configuration that varies per deployment without rebuilding (multi-tenant, region-specific endpoints), fetch the config at runtime from your backend or inject it into window.__APP_CONFIG__ via the HTML at deploy time. Env vars are for build-time defaults; runtime config is a different problem.

If it's in the client bundle, it's public. Don't put database URLs, API secrets, or auth tokens in any VITE_-prefixed variable. The JS file is plaintext. Anyone can view-source.

Code

.env files — what goes where·bash
# .env (committed) — defaults that are safe to know
VITE_APP_NAME=My App
VITE_API_URL=/api

# .env.development.local (gitignored) — dev-only override, never in the build
VITE_API_URL=http://localhost:8000

# .env.production (committed) — prod-only safe defaults
VITE_API_URL=https://api.myapp.com

# .env.production.local (gitignored) — deployment-specific
VITE_SENTRY_DSN=https://...@sentry.io/...

# Server-only secrets — NO VITE_ prefix, NEVER in any client bundle
DATABASE_URL=postgres://...
ADMIN_TOKEN=secret
Reading env vars in code·tsx
// import.meta.env.VITE_X — typed as string (or undefined for unset).
const apiUrl: string = import.meta.env.VITE_API_URL;

// Type-safe with declarations in src/vite-env.d.ts:
interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_APP_NAME: string;
  readonly VITE_SENTRY_DSN?: string;
}
interface ImportMeta {
  readonly env: ImportMetaEnv;
}

// Built-in modes:
const isDev = import.meta.env.DEV;       // true in dev
const isProd = import.meta.env.PROD;     // true in build/preview
const mode = import.meta.env.MODE;       // 'development' | 'production' | custom

External links

Exercise

In your bootstrap project: create .env with VITE_APP_NAME and VITE_API_URL. Create .env.development.local with a different VITE_API_URL. Use both in App.tsx via import.meta.env. Verify dev shows the .env.development.local value while npm run build falls back to the .env value — the .development infix scopes the override to dev only. A plain .env.local loads in every mode (build included), so it would keep the override in the production build and the verify-step wouldn't reproduce. Then add a non-VITE_ variable (SECRET=hi) and confirm it never appears in the built bundle (grep dist/assets/).
Hint
The grep for dist/assets/*.js should return the VITE_ values but never the SECRET. If SECRET shows up, you've referenced it via import.meta.env.SECRET somewhere — Vite's static check should have prevented it.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Pippawarm

Comments 1

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.
  1. This thread was promoted to a leaf-bound Issue
    Chan
    Chan

    Hey guys, if you're doing the exercise, the instructions are actually a bit misleading based on how Vite works.

    The exercise says to use .env.local and expects the production build to fall back to .env. But in Vite, .env.local overrides EVERYTHING—both dev and build. So if you use .env.local, your production build will still end up with your localhost URL.

    To make it work the way the exercise actually wants you to, just do this:

    Change the local filename from .env.local to .env.development.local

    This forces the localhost URL to only load during npm run dev. Then when you run npm run build, it'll properly switch over to the production URL in your .env.

    Save yourselves the headache and change the filename!