Skip to main content

Multi-Stage Builds

A common problem with Dockerfiles is that building an application requires a lot of tools — compilers, build systems, test runners, bundlers — that serve no purpose at runtime and only bloat the final image. A Node.js image that includes TypeScript, ts-node, jest, and all the devDependencies might be 800 MB. The same application, just the compiled JavaScript and production node_modules, might be 80 MB.

Multi-stage builds solve this by letting you use multiple FROM instructions in a single Dockerfile. Each FROM starts a new stage, and you can selectively COPY artefacts from one stage into another. Docker discards the intermediate stages — they are never part of the final image.


The Core Concept

Without multi-stage builds, the naive approach is to build inside the container and ship everything:

# Single-stage — ships build tools, source maps, devDependencies, etc.
FROM node:20

WORKDIR /app
COPY . .
RUN npm install # installs devDependencies too
RUN npm run build # compiles TypeScript
EXPOSE 3000
CMD ["node", "dist/index.js"]

This works but ships a bloated image. With multi-stage builds:

# ---- Stage 1: builder ----
FROM node:20 AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci # install all deps including devDependencies
COPY tsconfig.json .
COPY src/ ./src/
RUN npm run build # compile TypeScript → dist/

# ---- Stage 2: runtime ----
FROM node:20-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev # production deps only
COPY --from=builder /app/dist ./dist

EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]

The --from=builder flag in COPY references the named stage builder. Only the compiled dist/ directory crosses the stage boundary. The build tools, TypeScript source, source maps, and devDependencies stay in builder and are discarded.


FROM ... AS Syntax

You name a stage by appending AS <name> to the FROM instruction. Names are case-insensitive and can be referenced in later COPY --from and RUN --mount=from instructions.

FROM ubuntu:24.04 AS base
FROM base AS builder
FROM base AS tester
FROM base AS production

You can also reference a stage by its zero-indexed position number, but naming stages is far more readable.


COPY --from

COPY --from is the mechanism that transfers artefacts between stages.

# Copy from a named stage
COPY --from=builder /app/dist ./dist

# Copy a single file
COPY --from=builder /app/dist/index.js ./

# Copy from an external image (not just a stage in this Dockerfile)
COPY --from=nginx:alpine /etc/nginx/nginx.conf ./nginx.conf.reference

The last form — copying from an external image — is useful for grabbing well-known config templates or static binaries.


Building a Specific Stage

You can stop the build at any stage using --target:

# Build only the 'builder' stage (useful for testing the build step in CI)
docker build --target builder -t my-app:builder .

# Build the full production image
docker build -t my-app:latest .

--target is invaluable in CI pipelines where you might want to run tests in the builder stage before building the final image.


Real-World Example: Node.js TypeScript API

Here is a production-quality Dockerfile for a TypeScript Express API.

# ============================================================
# Stage 1 — deps: Install ALL dependencies (including dev)
# ============================================================
FROM node:20-alpine AS deps

WORKDIR /app
COPY package*.json ./
# ci installs exactly what's in the lockfile, reproducibly
RUN npm ci


# ============================================================
# Stage 2 — builder: Compile TypeScript
# ============================================================
FROM deps AS builder

COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# Output: /app/dist/


# ============================================================
# Stage 3 — tester: Run tests (optional; used in CI with --target tester)
# ============================================================
FROM builder AS tester

COPY tests/ ./tests/
RUN npm test


# ============================================================
# Stage 4 — production: Minimal runtime image
# ============================================================
FROM node:20-alpine AS production

# Security: non-root user
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs appuser

WORKDIR /app

# Only production dependencies
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

# Copy compiled code from builder (NOT the full deps, NOT the source)
COPY --from=builder --chown=appuser:nodejs /app/dist ./dist

USER appuser

EXPOSE 3000
ENV NODE_ENV=production PORT=3000

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1

CMD ["node", "dist/index.js"]

Build commands

# Run tests only
docker build --target tester -t my-app:test .

# Build production image
docker build --target production -t my-app:latest .

# Full build (defaults to the last stage — production)
docker build -t my-app:latest .

Size Comparison

To illustrate the difference, here are typical sizes for the same Node.js + TypeScript app at each stage:

Image variantApproximate sizeWhat's included
node:20 (single stage, all deps)~1.1 GBFull Debian, build tools, devDeps, source
node:20-alpine (single stage, all deps)~320 MBAlpine, build tools, devDeps, source
Multi-stage, node:20 runtime~220 MBDebian, prod deps, compiled JS only
Multi-stage, node:20-alpine runtime~75 MBAlpine, prod deps, compiled JS only
Multi-stage, node:20-alpine + distroless~55 MBDistroless, prod deps, compiled JS only

Multi-stage builds consistently produce images 4–10× smaller than naive single-stage builds. Smaller images mean faster pushes, faster pulls, and a smaller attack surface.


Example: Go Binary

Go compiles to a single statically-linked binary, making multi-stage builds especially effective:

# Stage 1: build the Go binary
FROM golang:1.22-alpine AS builder

WORKDIR /app
# Cache dependencies separately
COPY go.mod go.sum ./
RUN go mod download

COPY . .
# CGO_ENABLED=0 produces a fully static binary
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server

# Stage 2: scratch image (literally empty — just the binary)
FROM scratch

COPY --from=builder /app/server /server
# If your app needs TLS certificates
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

EXPOSE 8080
ENTRYPOINT ["/server"]

The scratch base image contains nothing. The final image contains only the binary and the CA certificates. The result is typically under 10 MB — compared to 800 MB+ for a Go development image.


Example: React Frontend (Build + Nginx)

# Stage 1: build the React app
FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Output: /app/dist/

# Stage 2: serve with nginx
FROM nginx:1.27-alpine

# Copy the built static files into nginx's serve directory
COPY --from=builder /app/dist /usr/share/nginx/html

# Copy a custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf

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

The final image contains only nginx and the compiled HTML/CSS/JS. Node.js and the build toolchain are gone.


Layer Caching Across Stages

Each stage has its own layer cache. The dependency-installation stage (copying package*.json and running npm ci) is cached independently from the compilation stage (copying source and running npm run build). This means:

  • A code change only invalidates the compilation layer, not the dependency install.
  • A dependency change invalidates both.

Structure your stages so that slow, infrequently-changing steps come first within each stage.


BuildKit and Parallel Stages

Docker BuildKit (enabled by default since Docker 23) can build independent stages in parallel, further reducing build time. Stages that do not depend on each other run concurrently.

# BuildKit is the default in modern Docker, but you can verify:
docker build --progress=plain .

If you have a builder and a tester stage that both start from deps but are independent of each other, BuildKit runs them at the same time.


Summary

Multi-stage builds are one of the most impactful practices for production Docker images:

GoalTechnique
Separate build and runtimeUse FROM ... AS <stage> + COPY --from
Run tests in CI, skip in proddocker build --target tester
Minimise image sizeUse Alpine or distroless as the final base
Static binariesUse FROM scratch for Go, Rust
Keep secrets out of the final imageOnly pass them to builder stages via --secret

Once you adopt multi-stage builds, single-stage Dockerfiles that ship build tools to production will feel obviously wrong.