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.
Table of Contents
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.