Injecting environment variables into a Dockerized Vite app at runtime
Vite bakes import.meta.env into the bundle at build time, so one Docker image cannot be promoted across environments. This covers why that happens and a pattern where the container writes an env.js file at start-up from real process env.
You containerise a Vite app, push the image to your registry, and then find you cannot point it at staging and production without rebuilding. The API URL is hard-baked into the JavaScript. This post covers why that happens, the ways around it, and a runtime-injection setup you can drop into a project.
Why import.meta.env is frozen at build time#
Vite does not read environment variables in the browser. There is no process.env there. Instead, during vite build it performs a static text replacement. Every occurrence of import.meta.env.VITE_API_URL in your source is substituted with a string literal via define, then the bundle is minified and tree-shaken around the result:
// your source
const api = import.meta.env.VITE_API_URL + "/orders"
// after "vite build" with VITE_API_URL=https://api.stg.example.com
const api = "https://api.stg.example.com/orders"
// dead-code elimination even collapses this
if (import.meta.env.VITE_FEATURE_X === "true") { ... }
// → (removed entirely when the value was "false")In a Docker build, the RUN npm run build step is where this happens. Whatever VITE_* values are present in that build stage get welded into dist/. The resulting image is environment-specific. That breaks the build-once-promote-everywhere principle, and your registry fills up with near-identical images that differ only by a URL.
VITE_* (configurable via envPrefix) reach import.meta.env. That prefix rule still applies to the dev workflow described below; runtime injection is a separate channel.The options, ranked#
| Approach | Immutable image? | Cost |
|---|---|---|
| Build a separate image per environment | No | Simplest, but N builds, N images, slow promotion, drift risk |
| Placeholder tokens + envsubst/sed on start | Yes | Fragile string replacement across minified JS; token collisions |
| Container generates env.js at start-up | Yes | One small script; the pattern this post recommends |
| nginx sub_filter on responses | Yes | Works, couples config to the web server, per-request cost |
| Fetch /config.json at app boot | Yes | Clean, but adds a render-blocking request or a loading gate |
The generate-env.js-at-start-up approach wins for most apps. The image stays immutable, there is no risky find-and-replace inside bundled code, and the browser gets the values synchronously before the app boots.
The runtime env.js pattern#
The shape of it:
- Build the static site once, with no real secrets, into an nginx image.
- On container start, an entrypoint script reads the real environment and writes
/usr/share/nginx/html/env.js. index.htmlloadsenv.jsbefore the app bundle, so it populateswindow.__ENV__.- App code reads config through a small accessor that prefers
window.__ENV__and falls back toimport.meta.envfor localvite dev.
1. The config accessor
// src/env.ts
type AppEnv = {
API_URL: string
SENTRY_DSN: string
FEATURE_NEW_CHECKOUT: boolean
}
// window.__ENV__ is written by env.js in the container. In "vite dev" it does
// not exist, so we fall back to import.meta.env (the .env / .env.local file).
const runtime = (typeof window !== "undefined" && (window as any).__ENV__) || {}
function read(key: string, devFallback: string | undefined): string {
const v = runtime[key] ?? devFallback
if (v === undefined || v === "") {
console.warn("[env] missing config value: " + key)
return ""
}
return v
}
export const env: AppEnv = {
API_URL: read("API_URL", import.meta.env.VITE_API_URL),
SENTRY_DSN: read("SENTRY_DSN", import.meta.env.VITE_SENTRY_DSN),
FEATURE_NEW_CHECKOUT:
read("FEATURE_NEW_CHECKOUT", import.meta.env.VITE_FEATURE_NEW_CHECKOUT) === "true",
}Everywhere else in the app, import { env } from this file. Do not touch import.meta.env or window.__ENV__ directly anywhere else. One accessor keeps the fallback logic and the warnings in a single place.
2. index.html loads env.js first
<!-- index.html -->
<head>
<!-- must come BEFORE the module script Vite injects -->
<script src="/env.js"></script>
</head>A committed placeholder public/env.js keeps vite dev and the build from 404-ing:
// public/env.js: placeholder, overwritten in the container at runtime
window.__ENV__ = {}3. The entrypoint script
This is the whole trick. It only ever emits keys from an explicit allowlist, so a stray AWS_SECRET_ACCESS_KEY in the container environment can never leak into a public file.
#!/bin/sh
# docker-entrypoint.sh
set -eu
TARGET=/usr/share/nginx/html/env.js
# Explicit allowlist: ONLY these are written to the browser-visible file.
KEYS="API_URL SENTRY_DSN FEATURE_NEW_CHECKOUT"
echo "window.__ENV__ = {" > "$TARGET"
for key in $KEYS; do
# POSIX indirect expansion
value=$(printenv "$key" || true)
printf ' "%s": "%s",\n' "$key" "$value" >> "$TARGET"
done
echo "};" >> "$TARGET"
echo "Wrote runtime config:"
cat "$TARGET"
exec "$@"jq) so you do not produce invalid JavaScript or open a trivial injection. Never put anything secret in env.js. It is served to every visitor.4. The Dockerfile
# Stage 1: build
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# No real env values here. The bundle only needs the VITE_ vars that have
# safe build-time defaults (or none at all).
RUN npm run build
# Stage 2: serve
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /docker-entrypoint.d/40-env.sh
RUN chmod +x /docker-entrypoint.d/40-env.sh
# nginx:alpine runs every /docker-entrypoint.d/*.sh before starting nginx,
# so no custom ENTRYPOINT is needed.Dropping the script into /docker-entrypoint.d/ piggybacks on the official nginx image's own init runner. If you use a different base, wire it up explicitly:
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]5. nginx: SPA fallback and never cache env.js
server {
listen 8080;
root /usr/share/nginx/html;
# Hashed assets: cache hard
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Runtime config: must never be cached, or a promoted container
# keeps serving the previous environment's values
location = /env.js {
add_header Cache-Control "no-store, must-revalidate";
expires -1;
}
# SPA history fallback
location / {
try_files $uri /index.html;
}
}env.js is cached by the browser or a CDN, you promote the image to production and it still talks to staging until the cache expires.no-store on env.js is not optional. The app bundle stays long-cached because its filename is content-hashed.Running it#
docker build -t myapp:1.4.0 .
# same image, different environments
docker run -p 8080:8080 \
-e API_URL=https://api.staging.example.com \
-e SENTRY_DSN=https://xxx@sentry.io/1 \
-e FEATURE_NEW_CHECKOUT=false \
myapp:1.4.0
docker run -p 8080:8080 \
-e API_URL=https://api.example.com \
-e FEATURE_NEW_CHECKOUT=true \
myapp:1.4.0In Kubernetes the same keys come from a ConfigMap via envFrom; the entrypoint does not care where the environment came from.
BUILD TIME (docker build) RUN TIME (docker run -e …)
───────────────────────── ──────────────────────────
vite build entrypoint reads process env
│ static replace │ allowlist only
▼ ▼
dist/assets/index-a1b2c3.js /usr/share/nginx/html/env.js
import.meta.env.* → literals window.__ENV__ = { API_URL: "…" }
│ │
└──────────── src/env.ts ◀─────────┘
prefers window.__ENV__, falls back to import.meta.envWhen to use a different approach#
- Static host / CDN with no container (Netlify, S3, Hostinger): there is no start-up hook, so either build per environment or fetch a
/config.jsonthat the platform serves. This blog runs on shared hosting and takes the build-per-deploy route. - You need typed, validated config with async sources: fetch
/config.jsonat boot, validate with zod, render a splash until it resolves. Costs one request; gains schema safety. - Edge/SSR (Next, Remix, Astro SSR): you have a real server, so read
process.envper request and pass values into the document. Noenv.jsneeded.
import.meta.env is a compile-time substitution, so a Docker image built with it is environment-specific. Keep the image immutable by having the container write an allowlisted env.js on start-up, load it before the bundle, read it through one accessor with an import.meta.env fallback for dev, and serve it with Cache-Control: no-store.