DEV Community

Cover image for Vite in production: the Azure pod checklist nobody gives you
Monalisa Das
Monalisa Das

Posted on Originally published at monalisadas-knowme.vercel.app

Vite in production: the Azure pod checklist nobody gives you

Part 2 of the CRA→Vite series. The code migration is done — but six things in the deployment layer will still break your AKS pod. Build output dir, Node version, environment variable baking, ConfigMaps, nginx config, and three verification commands that prove it's all working.

Part 1 (cra-to-vite-migration) covered the code changes. Your import.meta.env is working locally, vite.config.ts is clean, and npm run dev runs. Then you push to Azure DevOps, the pipeline runs, and something in the deployment layer breaks. This post is the checklist for that — six things that break after the code is right, all in the deployment layer. I hit every one of these deploying to AKS.

1. Build output: build/ → dist/

CRA outputs to build/. Vite outputs to dist/. Every reference to the old path breaks — the Dockerfile COPY, the nginx root if it's hardcoded, and the CI artifact path. These fail silently if the COPY doesn't error (e.g. COPY with a wildcard that matches nothing).

# -- Before (CRA) -------------------------------------------------------------
FROM node:16-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html   # ← breaks
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

# -- After (Vite) -------------------------------------------------------------
FROM node:22-alpine AS builder          # Node version also changes — see §2
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html    # ← fixed
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

Also update the Azure DevOps publish step. This is the most common missed change — teams fix the Dockerfile but leave the pipeline artifact path pointing at build/.

# Azure DevOps — PublishBuildArtifacts
- task: PublishBuildArtifacts@1
  inputs:
    pathToPublish: '$(Build.SourcesDirectory)/dist'   # was: /build
    artifactName: 'frontend'
    publishLocation: 'Container'
Enter fullscreen mode Exit fullscreen mode

2. Node base image: must be 18+

Vite 5 requires Node 18+. If your Dockerfile still has FROM node:14 or FROM node:16, the npm ci step may succeed but the build step fails with a cryptic ESM resolution or engine compatibility error. Update the builder stage and any NodeTool task in the pipeline.

# Azure DevOps pipeline — Node version
- task: NodeTool@0
  inputs:
    versionSpec: '22.x'     # was '16.x' or '14.x'
  displayName: 'Install Node.js'
Enter fullscreen mode Exit fullscreen mode

Node 22 is the active LTS as of 2026 — Node 20 reached EOL in April 2026. There is no reason to stay on 16 — Vite 5 won't let you, and Node 20 has measurable startup and fetch improvements that matter for SSR or edge functions downstream.

3. Environment variables — this is the one that gets teams

CRA used REACT_APP_. Vite uses VITE_. Both prefix conventions are the mechanism that gates which env vars get included in the bundle. You need to rename everywhere the variables are defined: ConfigMaps, Secrets, Azure DevOps variable groups, .env files, and any scripts that set them.

# Kubernetes ConfigMap — rename all REACT_APP_* keys
apiVersion: v1
kind: ConfigMap
metadata:
  name: frontend-config
  namespace: production
data:
  # Before                              # After
  # REACT_APP_API_URL: ...              VITE_API_URL: "https://api.example.com"
  # REACT_APP_FEATURE_FLAGS: ...        VITE_FEATURE_FLAGS: "dark-mode,new-nav"
  # REACT_APP_BUILD_ENV: ...            VITE_BUILD_ENV: "production"
  VITE_API_URL: "https://api.example.com"
  VITE_FEATURE_FLAGS: "dark-mode,new-nav"
  VITE_BUILD_ENV: "production"
Enter fullscreen mode Exit fullscreen mode
# Azure DevOps pipeline variables section
variables:
  # Before
  # REACT_APP_API_URL: $(API_URL_FROM_KEYVAULT)
  # REACT_APP_TENANT_ID: $(TENANT_ID)
  # After
  VITE_API_URL: $(API_URL_FROM_KEYVAULT)
  VITE_TENANT_ID: $(TENANT_ID)
Enter fullscreen mode Exit fullscreen mode

The build-time baking problem

This is the part CRA teams get wrong after migration. Vite statically replaces import.meta.env.VITE_* at build time — the compiled JS bundle contains the literal string value, not a reference to an environment variable. If you inject env vars into the pod at runtime via a ConfigMap (a common Kubernetes pattern), those values are NOT available to your Vite app. By the time the pod starts, the build has already happened and the values are baked in.

This means the env vars must be present when npm run build runs, not when the container starts. In a multi-stage Docker build, pass them as build args from the CI pipeline:

# Dockerfile — accept build args in the builder stage
FROM node:22-alpine AS builder
WORKDIR /app

# Declare args — these are available during npm run build
ARG VITE_API_URL
ARG VITE_TENANT_ID
ARG VITE_BUILD_ENV=production

# Vite picks these up via import.meta.env during the build
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_TENANT_ID=$VITE_TENANT_ID
ENV VITE_BUILD_ENV=$VITE_BUILD_ENV

COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode
# Azure DevOps — pass vars as Docker build args
- task: Docker@2
  displayName: 'Build image'
  inputs:
    command: 'build'
    dockerfile: 'Dockerfile'
    arguments: >
      --build-arg VITE_API_URL=$(VITE_API_URL)
      --build-arg VITE_TENANT_ID=$(VITE_TENANT_ID)
      --build-arg VITE_BUILD_ENV=production
    tags: '$(Build.BuildId)'
Enter fullscreen mode Exit fullscreen mode

Runtime injection with window.env (if you need the same image across environments)

Some teams run the same Docker image in dev, staging, and prod — baking environment-specific values defeats that. If you need runtime injection, the Vite-compatible pattern is window.env: a JavaScript file served by nginx that the app reads at startup, generated from actual pod environment variables via an entrypoint script.

#!/bin/sh
# docker-entrypoint.sh — generates config.js from pod env vars at startup
# Mount this as the container ENTRYPOINT in the Dockerfile

cat > /usr/share/nginx/html/config.js <<EOF
window._env_ = {
  API_URL: "${VITE_API_URL:-}",
  TENANT_ID: "${VITE_TENANT_ID:-}",
  BUILD_ENV: "${VITE_BUILD_ENV:-production}"
};
EOF

exec nginx -g 'daemon off;'
Enter fullscreen mode Exit fullscreen mode
<!-- index.html — load config.js before your app bundle -->
<head>
  <meta charset="UTF-8" />
  <script src="/config.js"></script>  <!-- must be before the module scripts -->
</head>
Enter fullscreen mode Exit fullscreen mode
// src/utils/env.ts — reads from window._env_ at runtime, falls back to build-time baked values
declare global {
  interface Window {
    _env_?: Record<string, string>
  }
}

export const env = {
  apiUrl:     window._env_?.API_URL     ?? import.meta.env.VITE_API_URL     ?? '',
  tenantId:   window._env_?.TENANT_ID   ?? import.meta.env.VITE_TENANT_ID   ?? '',
  buildEnv:   window._env_?.BUILD_ENV   ?? import.meta.env.VITE_BUILD_ENV   ?? 'production',
} as const

// Usage: import { env } from '@/utils/env'
// env.apiUrl, env.tenantId — same API regardless of whether values are baked or runtime-injected
Enter fullscreen mode Exit fullscreen mode

4. CI/CD pipeline: the output dir is the only change

The build command doesn't change — npm run build still works. The output dir does. Audit three places in your Azure DevOps pipeline YAML:

# Full Azure DevOps pipeline snippet — before and after comparison
stages:
- stage: Build
  jobs:
  - job: BuildFrontend
    steps:
    - task: NodeTool@0
      inputs:
        versionSpec: '22.x'                # was: '16.x'

    - script: npm ci
      displayName: 'Install dependencies'

    - script: npm run build
      displayName: 'Vite build'             # command unchanged
      env:
        VITE_API_URL: $(VITE_API_URL)       # was: REACT_APP_API_URL
        VITE_TENANT_ID: $(VITE_TENANT_ID)   # was: REACT_APP_TENANT_ID

    # 1. Copy step — update source path
    - task: CopyFiles@2
      inputs:
        sourceFolder: '$(Build.SourcesDirectory)/dist'    # was: /build
        contents: '**'
        targetFolder: '$(Build.ArtifactStagingDirectory)'

    # 2. Publish step — update pathToPublish
    - task: PublishBuildArtifacts@1
      inputs:
        pathToPublish: '$(Build.ArtifactStagingDirectory)'
        artifactName: 'frontend'

    # 3. Docker build — update build args as shown in §3
    - task: Docker@2
      inputs:
        command: 'buildAndPush'
        dockerfile: 'Dockerfile'
        arguments: '--build-arg VITE_API_URL=$(VITE_API_URL)'
Enter fullscreen mode Exit fullscreen mode

5. nginx config: usually no change — but verify one line

If your nginx config already has try_files $uri $uri/ /index.html, it works for Vite exactly as it did for CRA. SPA routing is SPA routing. The one edge case: if you have a hardcoded root that includes the word 'build' (uncommon but real in legacy configs copied from tutorials).

# nginx.conf — confirm this is what you have
server {
    listen 80;
    server_name _;

    # root points to the COPY target — already correct after Dockerfile fix
    root /usr/share/nginx/html;
    index index.html;

    location / {
        # This line is the one that matters for SPA routing
        # If it's here, no changes needed for Vite
        try_files $uri $uri/ /index.html;
    }

    # Cache built assets aggressively — Vite output hashes match CRA behavior
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # If you had a /static/ location for CRA's static/ dir — remove it
    # Vite doesn't produce a static/ subdirectory
}
Enter fullscreen mode Exit fullscreen mode

6. Verification: three commands that prove it's working

Run these against the running pod before marking the migration done.

# 1. Confirm dist files landed in the right place
# Should show .js and .css files with content hashes in their names (Vite default)
kubectl exec -it <pod-name> -n <namespace> -- ls /usr/share/nginx/html/assets/

# Expected output: index-Cd7FkVHB.js  index-DiwrgTda.css  ...
# If empty or missing: check COPY path in Dockerfile
Enter fullscreen mode Exit fullscreen mode
# 2. Confirm env vars were baked — the variable NAME should NOT appear in the bundle
# The literal value is inlined; the VITE_API_URL string itself should be gone
kubectl exec -it <pod-name> -n <namespace> -- sh -c \
  "grep -rl 'VITE_API_URL\|REACT_APP_' /usr/share/nginx/html/assets/ 2>/dev/null"

# Expected output: (nothing)
# If REACT_APP_ still appears: old bundle in the image — rebuild with --no-cache
# If VITE_API_URL appears: env var wasn't substituted — check that build args reached the builder stage
Enter fullscreen mode Exit fullscreen mode
# 3. Smoke test SPA routing — non-root routes must return 200, not 404
# A 404 here means try_files is missing or nginx config wasn't updated
kubectl exec -it <pod-name> -n <namespace> -- sh -c \
  "curl -s -o /dev/null -w '%{http_code}' http://localhost/dashboard/settings"

# Expected output: 200
# If 404: check try_files $uri $uri/ /index.html in nginx.conf
# If 500: check nginx error log: kubectl logs <pod-name> -n <namespace>
Enter fullscreen mode Exit fullscreen mode

If all three pass, the migration is complete at the infrastructure layer. The next thing to watch in the first 24 hours: cache headers on the index.html itself. Vite hashes asset files but index.html is unhashed — make sure your nginx or CDN isn't caching it aggressively, or users will get the old app after deployments until the cache expires.

For the full before/after code — Dockerfile, ConfigMap, and Azure DevOps pipeline YAML — see the companion sandbox linked at the top of this post.

Top comments (0)