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.
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
- Build the image without passing the variable:
docker build -t dashboard-web . - Run it with the variable:
docker run -e VITE_API_HOST=api.staging.example.com -p 8080:80 dashboard-web - 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 withVITE_. Variables without the prefix are not exposed to client code at all. - A stale
.env.productionfile. The repo's.dockerignoreexcluded all.env*files on purpose, and there was no.env.productioncommitted. 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.htmlmatched 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 stagingloads.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 runsedover 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>withwindow.__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_HOSTdiffered in the deployment manifests. - Frontend errors tagged
ERR_NAME_NOT_RESOLVEDin our RUM tool went from 100 percent of sessions on the broken staging deploy to zero. - Cold-load timing showed
config.jsonadding about 18 ms at p75, served from the same nginx withCache-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 -nguard 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
- Vite docs: Env Variables and Modes — https://vite.dev/guide/env-and-mode
- Vite docs:
envPrefixoption — https://vite.dev/config/shared-options#envprefix - Docker docs: Build variables (
ARGandENV) — https://docs.docker.com/build/building/variables/ - Docker docs: Build secrets — https://docs.docker.com/build/building/secrets/
- nginx official Docker image: environment variables and entrypoint scripts — https://hub.docker.com/_/nginx
Add a label
Embed this post
<iframe src="https://hibug.me/vite-import-meta-env-undefined-production" width="700" height="420" frameborder="0" loading="lazy"></iframe>
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.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.