Web Development Bug Report

Vite import.meta.env variable undefined in the production Docker build

Our React app called https://undefined/v1/... in production while dev worked fine. Vite replaces import.meta.env at build time, and our Docker image set the variable only at runtime. We fixed the build and then moved to runtime config.

Daniel Tran Daniel Tran @fun_dev · May 20, 2025 · 9 min read · 13 Views
Banner: Vite import.meta.env variable undefined in a production Docker build

We had just split our customer analytics dashboard into a React frontend built with Vite and a separate API. On the first deploy to staging, the page loaded and immediately showed "Network error". The browser's Network tab explained why:

GET https://undefined/v1/reports?range=30d  net::ERR_NAME_NOT_RESOLVED

The code that built the URL was one line:

const API_BASE = `https://${import.meta.env.VITE_API_HOST}/v1`;

On every developer laptop with npm run dev, it pointed at api.staging.example.com. In the container, it was undefined, even though docker exec showed the variable was set in the container's environment:

$ docker exec dashboard-web printenv VITE_API_HOST
api.staging.example.com

Reproducing it

  1. Build the image without passing the variable: docker build -t dashboard-web .
  2. Run it with the variable: docker run -e VITE_API_HOST=api.staging.example.com -p 8080:80 dashboard-web
  3. Open the app and watch the first API call go to https://undefined/v1/....

Grepping the built bundle confirmed that the staging host was nowhere in it:

docker run --rm dashboard-web sh -c 'grep -c "api.staging.example.com" /usr/share/nginx/html/assets/*.js'
# /usr/share/nginx/html/assets/index-B8kQ2xLp.js:0

What we ruled out

  • Missing VITE_ prefix. That is the most common cause and it was our first guess, but the name started with VITE_. Variables without the prefix are not exposed to client code at all.
  • A stale .env.production file. The repo's .dockerignore excluded all .env* files on purpose, and there was no .env.production committed. That turned out to be relevant, but it was not a stale value.
  • A caching CDN serving an old bundle. The asset hash in index.html matched the file we grepped inside the container.

Root cause

Vite does not read environment variables when the browser runs the app. It reads them once, during vite build, and replaces import.meta.env.VITE_API_HOST in the source with the literal value it found. Our Dockerfile ran npm run build in a build stage where VITE_API_HOST did not exist, so there was no value to inline and the expression could only ever evaluate to undefined in the browser. Setting the variable with docker run -e later only changed the environment of nginx, which serves static files and never looks at it.

What is happening under the hood

A Vite production build outputs static HTML, JavaScript and CSS. There is no Node process at runtime to read process.env. To make environment values available, Vite statically replaces import.meta.env.* during the build:

Expression in source What ends up in the bundle
import.meta.env.MODE "production"
import.meta.env.VITE_API_HOST (set at build) "api.staging.example.com"
import.meta.env.VITE_API_HOST (not set at build) undefined
import.meta.env.API_HOST (no prefix) undefined regardless

Vite loads variables from the shell environment and from .env, .env.local, .env.[mode] and .env.[mode].local, with shell values taking priority. Only names starting with envPrefix (VITE_ by default) are exposed, which protects you from accidentally shipping server secrets to the browser.

In development the dev server performs the same replacement on each request, and it runs on your laptop where the variable is set, so the problem never shows up locally.

Environment

Component Version / value
Bundler Vite 6.3.5 with @vitejs/plugin-react 4.4
Framework React 19.1
Build image node:22-alpine (Node.js 22.15)
Runtime image nginx:1.27-alpine
CI GitHub Actions, docker/build-push-action
Relevant config .dockerignore excludes .env*; variable passed only via docker run -e

Minimal reproduction

# Dockerfile (broken)
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build            # VITE_API_HOST is not defined here

FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html

Who is affected

Every Vite single-page app built in CI or Docker, and the same pattern applies to Create React App (REACT_APP_), Next.js NEXT_PUBLIC_ values and Angular environment files: client-side "env variables" are build-time constants. Server-rendered frameworks can read real runtime env only in server code.

We did this in two steps: an immediate fix to unblock staging, and a structural fix so one image can move from staging to production.

The fix

Step 1: pass the value at build time. Docker build arguments become environment variables for RUN commands when re-declared with ENV:

FROM node:22-alpine AS build
WORKDIR /app
ARG VITE_API_HOST
ENV VITE_API_HOST=$VITE_API_HOST
COPY package*.json ./
RUN npm ci
COPY . .
RUN test -n "$VITE_API_HOST" || (echo "VITE_API_HOST is required" && exit 1)
RUN npm run build
docker build --build-arg VITE_API_HOST=api.staging.example.com -t dashboard-web:staging .

The test -n line makes the build fail instead of producing a broken bundle. We also typed the variable so TypeScript knows it exists:

// src/vite-env.d.ts
interface ImportMetaEnv {
  readonly VITE_API_HOST: string;
}
interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Step 2: move environment-specific values to runtime config. Building a separate image per environment meant the image we tested in staging was not the image we shipped to production. We switched to a small JSON file that nginx serves and the app fetches before rendering:

// src/config.ts
export type AppConfig = { apiHost: string; sentryEnv: string };

let config: AppConfig | null = null;

export async function loadConfig(): Promise<AppConfig> {
  const res = await fetch('/config.json', { cache: 'no-store' });
  if (!res.ok) throw new Error(`config.json ${res.status}`);
  config = (await res.json()) as AppConfig;
  return config;
}

export function getConfig(): AppConfig {
  if (!config) throw new Error('loadConfig() must run before getConfig()');
  return config;
}
// src/main.tsx
loadConfig().then(() => {
  createRoot(document.getElementById('root')!).render(<App />);
});

The official nginx image runs envsubst on files in /etc/nginx/templates/*.template at startup and writes the result to /etc/nginx/conf.d. We used a small entrypoint script instead, because the config file lives in the web root:

#!/bin/sh
# docker-entrypoint.d/40-config-json.sh
set -eu
: "${API_HOST:?API_HOST is required}"
: "${SENTRY_ENV:=production}"
cat > /usr/share/nginx/html/config.json <<EOF
{"apiHost":"${API_HOST}","sentryEnv":"${SENTRY_ENV}"}
EOF

Scripts in /docker-entrypoint.d/ run before nginx starts in the official image, so the same image now runs anywhere with -e API_HOST=....

Alternatives and trade-offs

  • Build per environment with --mode. vite build --mode staging loads .env.staging. Simple and zero runtime cost, but you ship different artifacts to each environment.
  • Placeholder replacement at container start. Build with a value like __API_HOST__ and run sed over the JS files on startup. It works, but it mutates hashed assets, which breaks Subresource Integrity and makes CDN caching unpredictable.
  • Inline config in index.html. An entrypoint writes a <script> with window.__CONFIG__. Saves one request, but needs a CSP allowance for that inline script or a nonce.

We kept build-time variables only for values that never change between environments, such as the app version.

How to test the fix

docker build -t dashboard-web:test .
docker run --rm -d -p 8080:80 -e API_HOST=api.staging.example.com --name dw dashboard-web:test
curl -s http://localhost:8080/config.json
# {"apiHost":"api.staging.example.com","sentryEnv":"production"}

# The bundle must not contain environment-specific hosts any more
docker exec dw sh -c 'grep -l "api.staging.example.com" /usr/share/nginx/html/assets/*.js' && exit 1 || echo ok
docker rm -f dw

A Playwright test then loads the page and asserts that the first API request goes to the configured host.

Confirming in production

  • The same image digest was promoted from staging to production; only API_HOST differed in the deployment manifests.
  • Frontend errors tagged ERR_NAME_NOT_RESOLVED in our RUM tool went from 100 percent of sessions on the broken staging deploy to zero.
  • Cold-load timing showed config.json adding about 18 ms at p75, served from the same nginx with Cache-Control: no-store, which we accepted.

Regression guard

  • CI greps the built dist/ for any hostname from our environment list, failing the build if one was baked in by mistake.
  • loadConfig() validates required keys and renders an explicit "configuration missing" screen instead of letting requests go to garbage URLs.
  • The Dockerfile test -n guard stays for the build-time variables we still use.

Security implications

Anything in import.meta.env that reaches client code, or anything in config.json, is public: anyone can read it in the bundle or by requesting the file. Never put API secrets, private keys or database URLs there, with or without the VITE_ prefix. Do not widen envPrefix to '', which would expose every variable in the build environment, including CI tokens. Build arguments can also be recovered from image history, so they are not a place for secrets either; use BuildKit secret mounts for anything sensitive needed during the build.

Prevention checklist

  • Remember that client env variables are build-time constants; decide per value whether it belongs in the build or in runtime config.
  • Fail the build when a required VITE_ variable is missing.
  • Promote one image across environments and inject differences at runtime.
  • Scan built assets in CI for environment-specific hostnames.
  • Keep secrets out of client bundles and out of Docker build args.

References

Add a label

#react #vite #docker #environment-variables #ci
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.