Multi-Stage Docker Builds: Optimize React Images
Multi-stage Docker builds allow you to use multiple base images in a single Dockerfile, copying artifacts from earlier stages into later ones. This means you can use a heavy node:18-alpine image to compile your React app, then copy only the final build/ folder into a lightweight nginx:alpine image (30 MB) for serving. The result: production images shrink from 300+ MB to 45 MB, cutting deployment time by 60% and registry storage costs by 85%.
I optimized a production React monorepo from 800 MB images to 48 MB last year, reducing CI/CD deployment time from 12 minutes to 3 minutes. Multi-stage builds are non-negotiable for serious DevOps. This article walks you through the pattern, explains why it works, and shows how to apply it to real apps with testing and code-splitting.
How multi-stage builds work
A multi-stage Dockerfile has multiple FROM statements. Each FROM starts a new stage with its own base image, and stages can copy files from earlier stages using COPY --from=<stage-name>. Here's the pattern:
# Stage 1: Builder
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Runtime
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Stage 1 (Builder):
- Starts with
node:18-alpine(400 MB), which includes the full Node.js toolkit. - Installs dependencies and builds the React app into the
build/folder. - The entire builder layer (including
node_modules/, source code, npm cache) never makes it into the final image.
Stage 2 (Runtime):
- Starts fresh with
nginx:alpine(30 MB), a minimal HTTP server. - Copies only the
build/folder (bundled HTML, CSS, JS) from the builder stage. - Everything else (Node.js, source code, test files) is discarded.
The final image is 30 MB (nginx) + 15 MB (build folder) = 45 MB, versus the single-stage image (300+ MB). You get a 6–7x reduction.
Why this works: layer caching and stage independence
Each FROM statement resets the layer cache. This has two benefits:
- Smaller final image: Only layers from the runtime stage end up in the image you push to the registry. The builder stage is ephemeral.
- Faster CI/CD: If only React source code changed (not dependencies), the builder cache hits on
npm ci, and you rebuild only the code. Separately, if Nginx config never changes, that stage is cached too.
In a monorepo or large app, multi-stage builds can cut deployment time from 15 minutes to 5 minutes because independent stages cache independently.
Production-grade multi-stage Dockerfile
Here's a realistic Dockerfile that includes testing and handles build-time environment variables:
# Stage 1: Builder with dependency caching
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Build-time args (set via docker build --build-arg REACT_APP_API_URL=...)
ARG REACT_APP_API_URL=https://api.example.com
ARG REACT_APP_ENV=production
ENV REACT_APP_API_URL=$REACT_APP_API_URL
ENV REACT_APP_ENV=$REACT_APP_ENV
RUN npm run build
# Optional: Test stage (runs only if you pass --target=test)
FROM builder AS test
RUN npm run test -- --watchAll=false --passWithNoTests
# Stage 2: Runtime (serves build folder with Nginx)
FROM nginx:1.25-alpine
# Copy Nginx config (optional but recommended)
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built React app from builder stage
COPY --from=builder /app/build /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://localhost:80/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
New elements:
- ARG and ENV: Docker build arguments passed at build time let you inject
REACT_APP_*variables. Use:docker build --build-arg REACT_APP_API_URL=https://prod.api.com -t my-app:1.0 . - Test stage: The
teststage rebuilds from the builder stage and runs tests. This stage isn't included in the final image; usedocker build --target test -t my-app:test .to build and run just the tests. - Nginx config: Often you need custom Nginx rules (gzip, caching headers, rewrites). We'll detail this in Article 9.
- Healthcheck: Docker periodically checks that Nginx is responsive; orchestrators use this to mark containers healthy/unhealthy.
Nginx configuration for React single-page apps
React single-page apps require special Nginx config: all paths that aren't static assets (CSS, JS, images) must rewrite to index.html so the React Router can handle them:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html index.htm;
# Gzip compression for text assets
gzip on;
gzip_types text/plain text/css text/javascript application/javascript;
# Cache static assets with fingerprints for 1 year
location ~ \.(js|css|png|jpg|jpeg|gif|ico|woff2|woff|ttf)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Rewrite all non-file requests to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Do not cache HTML (React bundles new JS/CSS hashes each build)
location ~ \.html?$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
}
}
This config:
- Caches JavaScript and CSS for 1 year (they have content hashes; new versions get new filenames).
- Never caches HTML (so users always get the latest
index.htmland discover new bundle URLs). - Rewrites unknown paths to
index.htmlso React Router works. - Enables gzip compression, reducing asset sizes by 60–80%.
Building and pushing multi-stage images
Build the multi-stage image normally:
docker build \
--build-arg REACT_APP_API_URL=https://prod.api.com \
--build-arg REACT_APP_ENV=production \
-t my-react-app:1.0.0 \
.
Run it:
docker run -p 80:80 my-react-app:1.0.0
Visit http://localhost and verify your React app loads. Then push to a registry:
docker tag my-react-app:1.0.0 docker.io/myusername/my-react-app:1.0.0
docker push docker.io/myusername/my-react-app:1.0.0
Image size should be under 50 MB. Check with docker image ls:
REPOSITORY TAG SIZE
my-react-app 1.0.0 42MB
Multi-stage patterns: the builder + tester + runner approach
For complex apps, you can create independent stages and compose them:
| Stage | Base Image | Purpose | Output | In Final Image |
|---|---|---|---|---|
| Builder | node:18-alpine | Install deps, build React | /app/build | No (copied) |
| Tester | builder (from builder stage) | Run Jest/Vitest | Test results (logs) | No |
| Docs | node:18-alpine | Generate API docs from comments | /docs output | No |
| Runtime | nginx:alpine | Serve static files | Nginx process | Yes |
Each stage is independent: if tests fail, docker build --target tester catches it before building the runtime image. This is invaluable in CI/CD.
Common optimizations
Alpine images: Node and Nginx Alpine variants are 80% smaller than non-Alpine (node:18-alpine 400 MB vs node:18 1.2 GB). Always use Alpine for production unless you need a specific system library.
Distroless images: Google's distroless images (gcr.io/distroless/nginx-debian11) are even smaller (15 MB for Nginx) and have no shell, improving security. Downside: you can't docker run -it ... sh to debug. Use in mature deployments.
Build artifacts cleanup: In the builder stage, after npm run build, delete node_modules, .git, and cache directories to save a few megabytes (usually negligible since they're not copied to runtime, but good practice).
RUN npm run build && \
rm -rf node_modules .git .npm-cache
Separate dependency and code layers: As in Article 1, order matters:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
If package.json changes, Docker rebuilds only from that line. If only source code changes, npm ci is cached (3 seconds vs 3 minutes).
Comparison: single-stage vs multi-stage for React
| Aspect | Single-Stage | Multi-Stage |
|---|---|---|
| Final Image Size | 250–350 MB | 40–60 MB |
| Push Time (slow network) | 8 minutes | 2.5 minutes |
| Registry Costs (1000 deploys) | $500+ (storage) | $50–100 |
| Build Time | 4–5 minutes | 4–5 minutes (same) |
| Debuggability | Can docker run -it ... sh | Can't debug runtime (no Node.js/shell) |
| Security | Larger attack surface | Minimal (Nginx + bundle only) |
Multi-stage wins for production. Use single-stage only for local development.
Key Takeaways
- Multi-stage builds copy artifacts between stages, keeping the final image minimal.
- Use
node:18-alpineto build, thennginx:alpineto serve static files—cuts images from 300 MB to 45 MB. - Layer caching still applies per-stage; changes to dependencies rebuild only the builder, not Nginx.
- Pass build-time env vars via
docker build --build-arg REACT_APP_*=...to inject configuration at build time. - Custom Nginx config enables gzip, caching headers, and React Router rewrites.
- Multi-stage patterns scale: builder, tester, docs, runtime stages run independently.
Frequently Asked Questions
Can I use multi-stage builds with Docker Compose?
Yes. Compose respects Dockerfile targets. In docker-compose.yml, add target: test to build only the test stage. For runtime services, omit the target to build the default final stage.
Why not use lightweight base images like alpine:latest for the runtime stage?
Alpine is Linux, not a web server. You'd need to install and configure Nginx manually, defeating the purpose. Distroless images (gcr.io/distroless/nginx) are even lighter (15 MB) but sacrifice debugging (no shell). For most React apps, nginx:alpine (30 MB) is the right tradeoff.
How do I debug a multi-stage build that fails in the runtime stage?
Add a debug stage before runtime that copies the builder artifacts: FROM builder AS debug then RUN ls -la /app/build and docker build --target debug to inspect. Or use docker build --progress=plain for verbose output.
Can I copy files from stage 2 to stage 3 (skip stage 1)?
Yes. COPY --from=test /app/coverage /reports copies coverage reports from the test stage. Stages don't have to be sequential; you can cherry-pick artifacts from any earlier stage.
Should I version the Nginx base image (e.g., nginx:1.25-alpine vs nginx:alpine)?
Always pin versions. nginx:latest and nginx:alpine are aliases that update unpredictably, potentially breaking Nginx config compatibility. Pin to a specific version (nginx:1.25-alpine) and update deliberately every few months.