React Docker Deployment: Multi-Stage Build and Nginx (2026)

Containerising a React app gives reproducible builds, consistent environments and easy deployment to any cloud platform. This guide covers multi-stage Dockerfiles for Vite SPAs and Next.js SSR apps, Nginx configuration for client-side routing, injecting environment variables at container startup, Docker Compose for local stacks, and production image hardening.

Vite SPA Dockerfile

# Dockerfile
# ── Stage 1: Build ──────────────────────────────────────────
FROM node:20-alpine AS builder

WORKDIR /app

# Copy lockfile and manifests first — layer cache for node_modules
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

COPY . .

# Build-time env vars baked into the bundle (VITE_ prefix)
ARG VITE_API_URL
ARG VITE_SENTRY_DSN
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN

RUN pnpm build   # Outputs to /app/dist

# ── Stage 2: Serve ──────────────────────────────────────────
FROM nginx:1.27-alpine AS runner

# Remove default nginx content
RUN rm -rf /usr/share/nginx/html/*

# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html

# Custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Nginx Configuration

# nginx.conf — handles SPA routing (all paths serve index.html)
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_types text/plain text/css application/json application/javascript
               text/xml application/xml image/svg+xml;
    gzip_min_length 1024;

    # Cache static assets aggressively — Vite adds content hashes to filenames
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;

    # SPA fallback — unknown routes serve index.html for React Router
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Health check endpoint for load balancers
    location /health {
        return 200 'OK';
        add_header Content-Type text/plain;
    }

    # Proxy API calls to backend (avoids CORS in dev-like setups)
    location /api/ {
        proxy_pass http://backend:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Next.js Dockerfile

# Dockerfile for Next.js (standalone output mode)
FROM node:20-alpine AS base

# ── Stage 1: Install deps ────────────────────────────────────
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile

# ── Stage 2: Build ──────────────────────────────────────────
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Next.js collects anonymous telemetry — disable in CI
ENV NEXT_TELEMETRY_DISABLED=1

RUN pnpm build

# ── Stage 3: Production runner ───────────────────────────────
FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs

# Next.js standalone output bundles server + minimal node_modules
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]

# next.config.ts — enable standalone output
export default {
  output: 'standalone',   // Required for the slim Docker image
}

Runtime Environment Variables

# Problem: Vite bakes VITE_* vars into the bundle at build time.
# If you want one image deployed to staging and production with different
# API URLs, you need runtime injection — not build-time args.

# Solution: inject a window.__ENV__ object at container start
# nginx.conf — serve /env-config.js dynamically
location = /env-config.js {
    add_header Content-Type application/javascript;
    return 200 "window.__ENV__ = { VITE_API_URL: '$API_URL', VITE_ANALYTICS_ID: '$ANALYTICS_ID' };";
}

# index.html
<script src="/env-config.js"></script>

# src/config.ts — read from window or build-time fallback
export const config = {
  apiUrl: (window as any).__ENV__?.VITE_API_URL ?? import.meta.env.VITE_API_URL ?? '',
}

# Dockerfile entrypoint script (alternative — envsubst)
# docker-entrypoint.sh
#!/bin/sh
# Replace placeholders in env-config.js template
envsubst < /usr/share/nginx/html/env-config.template.js \
         > /usr/share/nginx/html/env-config.js
exec nginx -g 'daemon off;'

# Dockerfile
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]

Docker Compose

# docker-compose.yml — full local stack
services:
  web:
    build:
      context: .
      args:
        VITE_API_URL: http://localhost:8080
    ports:
      - "3000:80"
    environment:
      - API_URL=http://api:8080
    depends_on:
      - api
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost/health"]
      interval: 30s
      timeout: 5s
      retries: 3

  api:
    image: myapp/api:latest
    ports:
      - "8080:8080"
    environment:
      - DATABASE_URL=postgres://user:pass@db:5432/myapp
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      retries: 5

volumes:
  postgres_data:

Image Optimization

# .dockerignore — exclude from build context
node_modules
.git
.next
dist
coverage
*.md
.env*
.DS_Store

# Build and tag
docker build \
  --build-arg VITE_API_URL=https://api.prod.com \
  -t myapp/web:$(git rev-parse --short HEAD) \
  -t myapp/web:latest \
  .

# Inspect image size
docker images myapp/web
# Vite + Nginx alpine image: ~25-35MB
# Next.js standalone image: ~180-220MB

# Multi-platform build (Apple Silicon + Linux AMD64)
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t myapp/web:latest \
  --push .

# Security scan
docker scout cves myapp/web:latest
Image size targets: A Vite SPA served by Nginx Alpine should be under 40 MB. A Next.js standalone image should be under 250 MB. If your image is larger, check that you are not copying node_modules from the builder stage — standalone output includes only the required server-side modules.