Runtime ENV Config for Vite: Build Once, Deploy Anywhere
The 12-factor app methodology has this neat idea: config belongs in the environment, not in the code. Your backend folks nod knowingly - they’ve been doing this forever with their fancy environment variables. But here we are, frontend developers, watching Vite cheerfully bake our environment variables into the bundle at build time, making true Vite runtime config harder than it should be.
Want to change an API URL? Rebuild. Different configs for dev, staging, and prod? Three separate builds. Docker images per environment? This increases storage usage in the container registry and adds latency to the CI/CD pipeline due to redundant builds. With multiple environments, frequent config changes and fast delivery cycles, at SIMPL we hit this wall hard building our platform.
This is Part 1 of a highly practical series on runtime configuration for Vite applications. We walk through how the SIMPL team moved from build-time baked values to a runtime-substituted, type-safe approach. By the end, you have a container-friendly setup that lets you build once and deploy anywhere with Vite - without rebuilds, elaborate workarounds, and developer experience compromises.
The Problem: When Vite Build-Time ENV Meets Reality
Here’s what happens when you build a Vite app. You pass some environment variables, Vite processes your code, and those variables references are now hardcoded strings in your bundle.
# What teams often end up doing
VITE_API_URL=https://dev-api.com npm run build:dev
VITE_API_URL=https://staging-api.com npm run build:staging
VITE_API_URL=https://prod-api.com npm run build:prod
# What we actually want
docker run -e VITE_API_URL=https://prod-api.com my-app:latest
Looks innocent enough at a first glance. This seemingly small constraint cascades into a mess of problems.
- Build multiplication: Multiple environment-specific builds (dev, staging, prod) inflate delivery time. Repeated builds slow hotfix and validation cycles.
- Image duplication: Separate Docker images differing only from embedded strings waste registry space and add cognitive load when promoting releases.
- 12‑factor drift: Values baked into JavaScript cannot change at deploy time, violating the config-in-env principle. Frontend should align with backend practice.
We needed one build artifact that works across all environments: same code, same bundles, different configuration injected at runtime. How do we achieve that?
Core Idea: Shift Vite Config to HTML for Runtime Substitution
Every JavaScript file in your browser has access to the window object. If you define window.env in your HTML before loading your application code, your code will be able to access it:
<!DOCTYPE html>
<html>
<head>
<script type="application/javascript">
// This runs first
window.env = {
VITE_API_URL: "https://prod-api.com"
};
</script>
</head>
<body>
<!-- Your app loads after window.env exists -->
<script type="module" src="/assets/index.js"></script>
</body>
</html>
The window object trick is nothing new. Engineering teams taking web app applications deployment seriously have used this
pattern for years.
The challenge is integrating it seamlessly into Vite’s build process without disrupting developer experience.
Manually populating window.env and rewriting code references would be tedious and an error-prone task.
We adopted vite-plugin-runtime-env. The plugin shifts
environment configuration out of JavaScript bundles into HTML where it can be substituted when a container starts.
Here’s how the flow works:
graph LR
A["import.meta.env"] -->|Build| B["window.env + ${placeholders}"]
B -->|Runtime| C["window.env + real values"]
The flow is surprisingly elegant. You keep using import.meta.env.VITE_* in source code.
During a production build the plugin rewrites those references to window.env.VITE_* and injects a <script> tag
defining a window.env object populated with placeholder strings such as "${VITE_API_URL}".
At container startup, envsubst runs over the HTML and replaces placeholders with actual environment variable values.
JavaScript bundles remain static and cacheable.
Only the HTML changes per environment, enabling runtime configuration
without code changes.
The important benefit is what does not change: you don’t refactor your codebase, you don’t write extra abstraction layers for developers to learn. The transformation happens at build time and substitution occurs at startup.
Implementation: From Zero to Deployed
Phase 1: Keep Types Explicit
Before adding the plugin, define environment variable types to catch mistakes early. A few lines of TypeScript avoid late discovery of undefined values.
Create or update env.d.ts:
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
// ... all your VITE_ prefixed vars
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
This tells TypeScript exactly what environment variables exist. Now when you type import.meta.env.VITE_ your editor
autocompletes the options. If you typo VITE_API_ULR, TypeScript yells at you before it becomes a production incident.
Now use your environment variables like you normally would:
const apiUrl = import.meta.env.VITE_API_URL;
export const apiClient = createClient({
baseURL: apiUrl,
});
Just regular import.meta.env scattered throughout your codebase, straightforward like nature intended.
Phase 2: Apply the Plugin
Install the plugin:
npm i -D vite-plugin-runtime-env
Add it to vite.config.ts:
import { defineConfig } from 'vite';
import runtimeEnv from 'vite-plugin-runtime-env';
export default defineConfig({
plugins: [
vue(),
runtimeEnv(),
// ... other plugins
],
});
That’s it. No options to configure, no environment-specific settings to tweak. The plugin is smart enough to figure out what needs to happen.
During a production build, vite-plugin-runtime-env performs two transformations: JavaScript rewriting and HTML
injection.
Transformation 1: JavaScript Rewriting
It replaces each import.meta.env.VITE_* with window.env.VITE_* in built assets while leaving source code untouched.
// src/api/client.ts (source)
const apiUrl = import.meta.env.VITE_API_URL;
export const apiClient = createClient({
baseURL: apiUrl,
});
// dist/assets/index-a1b2c3.js (compiled output)
const apiUrl = window.env.VITE_API_URL;
export const apiClient = createClient({
baseURL: apiUrl,
});
Transformation 2: HTML Injection
A <script> tag defining window.env with placeholder values is added to the HTML entry point.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>My Vite App</title>
<script type="application/javascript">
window.env = {
VITE_API_URL: "${VITE_API_URL}",
};
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/assets/index-a1b2c3.js"></script>
</body>
</html>
See those ${VITE_*} strings? Those are shell variable placeholders. They’re not actual values - they’re literal
strings that look like shell variables. This is intentional. When the container starts, we’ll use envsubst to replace
them with real environment variable values.
The timing here is crucial.
The window.env script runs first, setting up the global object.
Then your application code loads and tries to access window.env.VITE_API_URL,
which now exists and contains a placeholder string.
Phase 3: Substitute Placeholders at Runtime
These placeholders are ready to be substituted at runtime. In your Dockerfile, use envsubst to inject real environment values when the container starts:
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build
# Runtime stage
FROM nginx:alpine
# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html
# Preserve template with placeholders
COPY --from=builder /app/dist/index.html /usr/share/nginx/html/index.template.html
# Substitute placeholders and start nginx
CMD ["sh", "-c", "envsubst < /usr/share/nginx/html/index.template.html > /usr/share/nginx/html/index.html && nginx -g 'daemon off;'"]
After running a production build (npm run build), your output directory will look like this:
dist/
├── assets/
│ ├── index-a1b2c3.js ← App code (hashed, static)
│ └── styles-g7h8i9.css ← Styles (hashed, static)
└── index.html ← Contains window.env placeholders
All your JavaScript and CSS files have content hashes in their filenames - they’re immutable and can live in CDN cache forever. The HTML? That’s our runtime-configurable piece.
The key move here is the CMD. Instead of running nginx directly, we run a shell command that:
- Reads
index.template.html(with placeholders) - Pipes it through
envsubst(replaces${VAR}with environment values) - Writes the result to
index.html(the file nginx actually serves) - Then starts nginx
This process ensures that every time your container starts, the HTML is regenerated with the correct environment variables:
<script>
window.env = {
VITE_API_URL: "https://prod-api.com",
};
</script>
Now, you can run your container with different environment variables for each environment, using the same build artifact:
docker run -p 8080:80 \
-e VITE_API_URL=https://prod-api.com \
my-app:latest
Deploy to staging using the same image:
docker run -p 8080:80 \
-e VITE_API_URL=https://staging-api.com \
my-app:latest
The JavaScript bundles stay cached. The HTML gets regenerated on every container start with fresh config. nginx serves the dynamically generated HTML alongside your static assets.
Development vs Production Behavior
The plugin activates only for production builds. Local development (npm run dev) continues using standard Vite semantics:
.env files load, import.meta.env resolves at build-time for the dev server, and HMR operates normally. Everything is
vanilla Vite.
The trade-off is worth mentioning: if you build a production bundle locally, those environment variables you set
during build time are ignored.
The plugin replaces them with placeholders.
To test a production build, you’d need to
either run it in Docker with proper env vars, or manually edit the dist/index.html to replace the placeholders. It’s a
minor inconvenience that buys you massive deployment flexibility.
Verification
Time to verify this whole thing works. Build and run a test container:
docker build -t my-app:test .
# Run with test values
docker run -p 8080:80 \
-e VITE_API_URL=https://test-api.com \
my-app:test
Open the application in the browser at http://localhost:8080 (or the mapped port), open DevTools Console, and type:
window.env
Should show substituted values:
{
"VITE_API_URL": "https://test-api.com"
}
Restart with other values and verify the object updates while assets remain unchanged.
The Honest Trade-offs
Nothing’s free in engineering. Let’s talk about what you’re gaining and what you’re giving up.
What You Get
You’re now a bit more 12-factor compliant. One build artifact, infinitely reconfigurable at runtime. Your CI/CD pipeline gets faster because you’re building once instead of three times. Your Docker registry gets smaller because you’re storing one image instead of N.
The development experience stays pristine. Type safety still works. Your source code isn’t affected. Developers don’t need to learn new patterns. The complexity is hidden in the build process and containerization.
What It Costs
You’re adding a build plugin and some Docker orchestration. Not complicated, but not zero either. Someone on the team needs to understand how it works when things go wrong.
Everything in window.env is publicly visible and that visibility is not unique to this method.
Previously, the same
values were still publicly baked (just scattered) inside hashed JS bundles. Centralizing them simply makes the exposure
explicit.
Everybody on the internet can load the app, open the console, type window.env, and see the config.
This is fine for API URLs or feature flags. Not fine for secrets or API keys. Those belong in your backend, fetched
after authentication.
Should You Use This?
If you have multiple environments, container-based deployments, and a need to change config without rebuilding, yes. This solves a real problem.
If you’re building a static site, have one environment, or your CI/CD pipeline is straightforward without caching concerns, skip this. Use Vite’s normal build-time configuration and call it a day.
What’s Next: Part 2
Our team replaced build-time baked configuration with runtime substitution: move values to HTML, rewrite
references to window.env, and apply envsubst at container startup,
resulting in one build, many deployments and consistent
developer experience.
Standard web apps served by nginx benefit immediately. Request comes in, nginx serves HTML with fresh config, browser loads it, life is good.
But what if you’re building a Progressive Web App? Service workers cache aggressively for offline functionality. They don’t ask nginx for the HTML every time - that defeats the whole point of offline support. So your cached HTML has stale config. Container restarts with new environment variables, service worker doesn’t care, users see old config.
How do you balance fresh configuration with offline capabilities? How do you tell a service worker “cache everything except this one thing”? And when the user’s phone has no signal, what config do you show them? Part 2 addresses strategies for balancing offline capability with freshness.