🏗️ Multi-Stage Docker Builds
You need a whole toolchain to build an app — compilers, dev dependencies, test frameworks — but almost none of it to run the app. Multi-stage builds let you use every tool during the build, then ship only the finished artifact in a tiny, secure final image.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why single-stage images are bloated and insecure for production
- Write a multi-stage Dockerfile using named stages and
COPY --from - Apply the pattern to compiled, frontend, and full-stack applications
- Use BuildKit,
--target, and multi-platform builds for advanced workflows - Optimize layer caching and pick the right base image per stage
Estimated Time: 45–60 minutes • Difficulty: Intermediate
Hands-on: Convert a single-stage React + Node Dockerfile into an optimized multi-stage build.
In This Lesson
Why Multi-Stage?
A traditional single-stage Dockerfile does everything in one image: install dev dependencies, copy the source, build, and run. The result carries the compiler, the source code, the test framework, and every build artifact into production — none of which the running app needs.
# Single-stage: everything ends up in the shipped image
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install # includes dev dependencies
COPY . .
RUN npm run build # build tools stay in the image
CMD ["npm", "start"]
The drawbacks are real: bloated images (slow to pull and deploy), a larger attack surface (every extra package is a potential vulnerability), and source code shipped to production where it doesn't belong.
💡 A useful analogy: Building furniture needs a workshop full of saws, clamps, and sawdust. But you deliver only the finished chair — not the workshop. Multi-stage builds are the delivery truck that carries the chair and leaves the tools behind.
The Basics & Syntax
A multi-stage Dockerfile has multiple FROM statements. Each FROM starts a fresh stage. Name a stage with AS <name>, then pull artifacts forward with COPY --from=<name>. Only the last stage becomes the final image; earlier stages are discarded.
# --- Stage 1: build ---
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # all deps, including dev
COPY . .
RUN npm run build # produces /app/dist
# --- Stage 2: production runtime ---
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev # runtime deps only
# Pull ONLY the built output from the builder stage
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]
📖 The three key pieces
Multiple FROM: each one begins a new, independent stage.
AS name: labels a stage so you can reference it later.
COPY --from=name: copies files out of an earlier stage (or even an external image) into the current one.
build steps"] -->|COPY --from=builder| C["FROM node:alpine
final image"] B["FROM node AS test
test steps"] -->|COPY --from=test| C
You can have as many stages as you need, but most real builds use two to four: build, optional test, and the lean production runtime.
Use Cases by App Type
Compiled languages (Go, Rust)
The classic win: a Go toolchain image is ~800 MB, but the compiled static binary is a few MB. Build in the full image, copy just the binary into a minimal base.
# Build stage
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app .
# Final stage — tiny
FROM alpine:3.20
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/app /app
CMD ["/app"]
✅ Dramatic size reduction
From ~800 MB (Go toolchain) down to ~15 MB (binary + certificates). The final image contains no compiler and no source — just the executable.
Frontend apps (React, Vue)
Build the static bundle with Node, then serve it from a tiny web server like nginx. The Node runtime never ships.
# Build stage
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # produces /app/dist (Vite) or /app/build (CRA)
# Production stage — static files served by nginx
FROM nginx:1.27-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;"]
Full-stack apps
Use several build stages — one per component — then assemble the outputs in a single runtime stage.
# Frontend build
FROM node:20 AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
# Backend build
FROM node:20 AS backend-builder
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm ci
COPY backend/ .
RUN npm run build
# Final runtime
FROM node:20-alpine
WORKDIR /app
COPY backend/package*.json ./
RUN npm ci --omit=dev
COPY --from=backend-builder /app/backend/dist ./dist
COPY --from=frontend-builder /app/frontend/dist ./public
EXPOSE 3000
CMD ["node", "dist/server.js"]
Advanced Techniques
Targeting a specific stage
Build up to a named stage with --target. Perfect for CI: run tests in a test stage, and only build the production image if they pass.
# Build only up to the test stage (for CI)
docker build --target test -t myapp:test .
# Build the full production image
docker build -t myapp:1.4.0 .
BuildKit and parallelism
BuildKit is the default builder in modern Docker. Stages that don't depend on each other build in parallel, and it caches more intelligently. You rarely need to enable it explicitly anymore, but you can force it:
# BuildKit is default in Docker 23+, but you can be explicit
DOCKER_BUILDKIT=1 docker build -t myapp .
# BuildKit cache mount: keep the npm cache across builds
# (inside the Dockerfile)
# RUN --mount=type=cache,target=/root/.npm npm ci
Multi-platform builds
Build one image that runs on both Intel and ARM (think Apple Silicon and Graviton servers) with buildx:
# Build and push for two architectures at once
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t registry.example.com/myapp:1.4.0 \
--push .
Copying from external images
COPY --from can also pull from a published image, not just a build stage — handy for grabbing a config file or a prebuilt tool:
# Pull a file straight from an official image
COPY --from=nginx:1.27 /etc/nginx/nginx.conf /nginx.conf
Optimizing Builds
Layer caching: order matters
Copy dependency manifests and install before copying source code. Since source changes far more often than dependencies, this keeps the expensive install layer cached across rebuilds.
# BEFORE: any file change busts the npm cache
COPY . .
RUN npm install
# AFTER: deps cached separately from source
COPY package*.json ./
RUN npm ci
COPY . .
Right base image per stage
Different stages have different needs. Use a full SDK to build, a test image to test, and the smallest possible runtime to ship.
| Stage | Typical base | Why |
|---|---|---|
| Build | node:20, golang:1.23 | Needs the full toolchain |
| Test | cypress/included, language + test deps | Needs test frameworks |
| Production | alpine, distroless, scratch | Smallest, most secure runtime |
💡 distroless and scratch
Distroless images contain your app and its runtime but no shell or package manager — nothing for an attacker to pivot with. Scratch is completely empty; it's ideal for a statically-compiled Go or Rust binary that needs nothing else.
Hands-on Exercise
🏋️ Convert single-stage → multi-stage
Objective: Optimize a React + Node app that currently builds in one bloated stage.
Starting point:
# Single-stage Dockerfile
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
Your task:
- Add a builder stage that installs all deps and runs the build
- Add a lean production stage on an alpine base
- Install production dependencies only in the final stage
COPY --fromjust the build output and server- Run as a non-root user and add a health check
💡 Hint
Name the first stage FROM node:20 AS builder. In the final stage use npm ci --omit=dev, then COPY --from=builder --chown=appuser:appuser /app/build ./build. Create the user with addgroup/adduser before switching with USER.
✅ Sample solution
# --- Build stage ---
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# --- Production stage ---
FROM node:20-alpine
RUN addgroup -g 1001 appuser \
&& adduser -u 1001 -G appuser -s /bin/sh -D appuser
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder --chown=appuser:appuser /app/build ./build
COPY --from=builder --chown=appuser:appuser /app/server ./server
USER appuser
ENV NODE_ENV=production
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "server/index.js"]
Challenge extension: add a test stage runnable with --target test, and turn on a BuildKit cache mount for npm ci to speed up repeat builds.
🎯 Quick Quiz
Question 1: What does COPY --from=builder do in a multi-stage build?
Question 2: Which image ships to production in a well-designed multi-stage build?
Question 3: Why copy package*.json and install dependencies before copying the rest of the source?
Best Practices
| ✅ Do | ❌ Don't |
|---|---|
Name stages descriptively (builder, test, production) | Leave stages unnamed and hard to reference |
| Copy only the artifacts you need forward | COPY --from whole directories blindly |
| Use a minimal base for the final stage | Ship the SDK image as production |
| Order layers so deps install before source copy | Copy everything then install (busts cache) |
| Run as non-root in the final stage | Assume multi-stage alone makes you secure |
Use --target to run tests in CI | Rebuild the whole image just to test |
⚠️ Debugging a build that "loses" files
The most common multi-stage bug is a COPY --from path that doesn't match where the earlier stage actually put the artifact. Verify with docker build --target builder -t debug . then docker run --rm -it debug ls -la /app to inspect what really exists before copying.
Summary & Quiz
🎉 Key Takeaways
- Multi-stage builds separate the build environment from the runtime, so tools and source never ship.
- Each
FROMstarts a stage; name them withASand pull artifacts withCOPY --from. - The pattern shines for compiled, frontend, and full-stack apps — often shrinking images by an order of magnitude.
- Use
--target, BuildKit parallelism, andbuildxfor CI, speed, and multi-platform images. - Order layers for caching and pick the smallest safe base for the final stage.
📚 Further Reading
🚀 What's Next?
Now that your images are lean, we'll orchestrate several of them together with Docker Compose for production — health checks, networks, secrets, and scaling.
🎉 Nicely optimized!
Your images are small, secure, and fast to deploy. Time to wire multiple services together.