Skip to main content

🏗️ 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.
Multi-stage build flow Source code enters a build stage with full tooling, an optional test stage runs tests, and only the built artifact is copied into a small final runtime image. Build stage full SDK, deps Test stage test frameworks Final image tiny runtime ship only the built artifact crosses into the final image
Figure 1 — Tools and source stay in the build stages; only the finished artifact reaches production.

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.

flowchart LR A["FROM node AS builder
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
flowchart TD Base[Base stage] --> FE[Frontend build] Base --> BE[Backend build] Base --> Docs[Docs generation] FE --> Final[Final image] BE --> Final Docs --> Final

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.

StageTypical baseWhy
Buildnode:20, golang:1.23Needs the full toolchain
Testcypress/included, language + test depsNeeds test frameworks
Productionalpine, distroless, scratchSmallest, 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:

  1. Add a builder stage that installs all deps and runs the build
  2. Add a lean production stage on an alpine base
  3. Install production dependencies only in the final stage
  4. COPY --from just the build output and server
  5. 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 forwardCOPY --from whole directories blindly
Use a minimal base for the final stageShip the SDK image as production
Order layers so deps install before source copyCopy everything then install (busts cache)
Run as non-root in the final stageAssume multi-stage alone makes you secure
Use --target to run tests in CIRebuild 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 FROM starts a stage; name them with AS and pull artifacts with COPY --from.
  • The pattern shines for compiled, frontend, and full-stack apps — often shrinking images by an order of magnitude.
  • Use --target, BuildKit parallelism, and buildx for 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.