Portfolio/Writing/Injecting environment variables into a Dockerized Vite app at runtime

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:

js
// 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.

Only VITE_-prefixed vars are exposed at all
Vite refuses to expose bare env vars to client code as a safety measure. Only 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#

ApproachImmutable image?Cost
Build a separate image per environmentNoSimplest, but N builds, N images, slow promotion, drift risk
Placeholder tokens + envsubst/sed on startYesFragile string replacement across minified JS; token collisions
Container generates env.js at start-upYesOne small script; the pattern this post recommends
nginx sub_filter on responsesYesWorks, couples config to the web server, per-request cost
Fetch /config.json at app bootYesClean, 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:

  1. Build the static site once, with no real secrets, into an nginx image.
  2. On container start, an entrypoint script reads the real environment and writes /usr/share/nginx/html/env.js.
  3. index.html loads env.js before the app bundle, so it populates window.__ENV__.
  4. App code reads config through a small accessor that prefers window.__ENV__ and falls back to import.meta.env for local vite dev.

1. The config accessor

ts
// 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

html
<!-- 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:

js
// 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.

bash
#!/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 "$@"
Escape values you do not control
The script above is fine for URLs and flags. If a value could contain a double quote, backslash or newline, escape it (or emit JSON with 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

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:

dockerfile
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

nginx
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;
  }
}
The caching bug everyone hits once
If 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#

bash
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.0

In Kubernetes the same keys come from a ConfigMap via envFrom; the entrypoint does not care where the environment came from.

text
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.env
Build-time values are welded into the bundle; runtime values arrive via a file the container writes on start.

When 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.json that 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.json at 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.env per request and pass values into the document. No env.js needed.
Summary
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.

Next up
Module Federation: the trade-offs, and how it actually works

A ground-up look at Webpack Module Federation: the runtime container, remoteEntry, the shared scope and semver negotiation. Plus how the Vite implementation differs, and the failure modes that decide whether it is worth adopting.

Read next →