Skip to content
Home
Docker Multi-Stage Builds: 90% Smaller Secure Images

Docker Multi-Stage Builds: 90% Smaller Secure Images

Docker Docker 8 min read 1572 words Beginner ExcellentWiki Editorial Team

Docker multi-stage builds use multiple FROM statements in a single Dockerfile to separate build environments from runtime environments. Only the final stage’s filesystem is preserved in the published image, while all build tools, compilers, and intermediate artifacts are discarded. This technique typically reduces image size by 80-95% — transforming a 1.2 GB Go development image into a 15 MB production binary — while simultaneously improving security by eliminating unnecessary packages and attack surface.

The Problem Multi-Stage Solves

Before multi-stage builds, keeping images small required workarounds like separate build and runtime Dockerfiles maintained in different directories, or complex shell scripts that compiled code in one container and copied artifacts to another. These approaches were error-prone, hard to maintain in CI/CD, and violated the principle of single-source-of-truth for builds.

Multi-stage solves this by allowing both build and runtime definitions in a single Dockerfile:

FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html

The final image contains only Nginx and compiled static files — typically 25 MB instead of 300 MB. Docker’s official documentation describes multi-stage builds as “the single most impactful optimization technique” for Dockerfiles.

How Multi-Stage Builds Work

Each FROM instruction starts a new stage. Stages are numbered starting from 0, but naming them with AS makes the Dockerfile readable:

FROM image:tag AS stage-name
COPY --from=stage-name /source/path /destination/path

You can also reference external images as sources:

COPY --from=nginx:alpine /etc/nginx/nginx.conf /etc/nginx/nginx.conf

The COPY --from flag is the core mechanism — it copies files from any previous stage (or external image) into the current stage. Only files explicitly copied are included in the final stage. This selective copying is what enables the dramatic size reduction, as entire build environments are discarded at the end of the build process.

Language-Specific Patterns

Go: Minimal Scratch Images

Go binaries compile to statically linked executables, making them ideal for minimal runtime images:

FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server .

FROM alpine:3.19
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/server /server
EXPOSE 8080
CMD ["/server"]

Final image: ~15 MB. For even smaller images, use FROM scratch with COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/. This produces images as small as 5-8 MB — just the compiled binary and SSL certificates. Google’s Go team recommends this pattern for production Go deployments.

Python: Slim Runtime with User Packages

FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
EXPOSE 8000
CMD ["python", "app.py"]

Final image: ~150 MB vs 900 MB for the naive approach. The builder stage installs all Python packages; the runtime stage only copies the installed packages and the application code. This approach avoids including pip, setuptools, and package cache files in the final image.

Node.js: Production Dependencies Only

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
RUN cp -R node_modules /prod_modules
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /prod_modules node_modules
COPY --from=builder /app/dist dist
EXPOSE 3000
CMD ["node", "dist/main.js"]

This separates production dependencies (cached in prod_modules) from build dependencies. The final image excludes dev dependencies like TypeScript, test frameworks, and build tools. Node.js applications benefit significantly from this pattern because node_modules often exceeds 200 MB for development dependencies alone.

Advanced Multi-Stage Patterns

Multiple Build Dependencies

For projects with separate frontend and backend:

FROM node:20-alpine AS frontend-builder
WORKDIR /app
COPY frontend/ .
RUN npm ci && npm run build

FROM golang:1.22 AS api-builder
WORKDIR /app
COPY api/ .
RUN CGO_ENABLED=0 go build -o /api/server .

FROM nginx:alpine
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
COPY --from=api-builder /api/server /usr/share/nginx/html/api

This pattern is common for full-stack applications where a single Nginx container serves both the frontend static files and proxies API requests to the backend binary.

Testing Stages

Define a test stage that is never included in production:

FROM golang:1.22 AS base
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .

FROM base AS test
RUN go test ./... -v

FROM base AS build
RUN CGO_ENABLED=0 go build -o /app/server .

Build specific stages: docker build --target test -t myapp:test .. In CI, run tests as one stage and only build the production stage if tests pass. This integrates testing directly into the build pipeline without needing separate test containers.

BuildKit Cache Optimization

BuildKit’s cache mounts persist between builds, making subsequent compilations near-instantaneous:

# syntax=docker/dockerfile:1
FROM golang:1.22 AS builder
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -o /app/server .

The --mount=type=cache flag mounts a persistent cache directory that survives between builds. For Go, this caches downloaded modules and compiled object files. Subsequent builds skip downloading and recompiling unchanged dependencies. Docker’s BuildKit documentation reports that cache mounts can reduce build times by 50-80% for projects with large dependency graphs.

Layer Ordering for Maximum Cache Hits

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

Copy dependency manifests first, install dependencies, then copy source. This maximizes cache hits — editing source code does not invalidate the dependency layer. This ordering is emphasized in Docker’s best practices guide as the foundational caching optimization.

Security Benefits of Multi-Stage Builds

Multi-stage builds improve security through attack surface reduction. The final image contains only what is necessary at runtime — no compilers, no debuggers, no package managers, no source code. This eliminates entire classes of vulnerabilities.

Distroless Base Images

FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

FROM gcr.io/distroless/base-debian12
COPY --from=builder /app/server /server
EXPOSE 8080
CMD ["/server"]

Distroless images contain only the application and its runtime dependencies — no shell, no package manager, no utilities. According to Google’s security team, distroless images have 70% fewer vulnerabilities on average compared to equivalent Alpine or Debian-slim images, simply because there are fewer packages to have vulnerabilities.

CI/CD Integration

Multi-stage builds integrate naturally with CI/CD pipelines. The ability to build specific stages independently makes it possible to run tests in CI without building the full production image, and to build the production image only after tests pass.

# Build and run tests from the test stage
docker build --target test -t myapp:test .
docker run --rm myapp:test

# Build only the production stage for deployment
docker build --target production -t myapp:$CI_COMMIT_SHA .
docker push registry.example.com/myapp:$CI_COMMIT_SHA

This conditional build approach is widely adopted in production CI/CD systems. By separating test and build stages, the pipeline can fail fast on test failures without spending time on the production build. BuildKit’s --target flag makes this workflow straightforward — each stage is independently cacheable and buildable.

FAQ

How many stages should a Dockerfile have?

Two to four is typical. A base stage for dependencies, a test stage (optional, built with --target test), a build stage, and a runtime stage. More stages add complexity without proportional benefit.

Can I copy files from earlier stages in any order?

Yes. Files from any previous stage are available via COPY --from=stage-name. Stages are numbered sequentially, so stage 0 (first FROM) can copy from external images only. You cannot copy from future stages.

Do multi-stage builds work with Docker Compose?

Yes. Docker Compose builds each service independently using its Dockerfile. Multi-stage builds work within each service’s Dockerfile. Use target in Compose to specify which stage to build: build: { context: ., target: runtime }.

How do I debug the builder stage?

Build with a specific target and tag: docker build --target builder -t debug-builder ., then run a shell: docker run --rm -it --entrypoint /bin/sh debug-builder. This gives you access to the builder stage’s filesystem for inspection.

Are there downsides to multi-stage builds?

Build time can increase if both stages install overlapping dependencies (they cache separately). Each stage constructs a separate filesystem, so total build time is the sum of all stages. However, BuildKit’s parallel execution mitigates this by building unrelated stages concurrently.

Conclusion

Multi-stage builds are the single most impactful Dockerfile optimization. They reduce image size by 80-95%, improve security by eliminating build tools from production, and speed up deployments through smaller images. Implement them for every language by separating build dependencies from runtime dependencies, use BuildKit cache mounts for performance, and consider distroless base images for maximum security. For more optimization techniques, see our guides on Docker image optimization and Dockerfile best practices.

Advanced Multi-Stage Patterns

Multi-stage builds can have more than two stages. A three-stage pattern — compile, package, run — is common for Java and .NET applications. Use AS aliases to reference specific stages. Named stages enable selective builds with --target: docker build --target test . runs tests without building the final image. Cross-compilation in multi-stage: compile for ARM on an x86 builder GOARCH=arm64 GOOS=linux go build -o /app/server . in the builder stage. Copy artifacts from specific stages with COPY --from=build /app/server /server. Cache mounts (--mount=type=cache) persist package downloads across builds, dramatically speeding up repeated builds in CI. Build arguments (ARG) differentiate stages: ARG GO_VERSION=1.22\nFROM golang:${GO_VERSION} AS builder. External images as stages: COPY --from=node:18-alpine /usr/local/bin/node /usr/local/bin/node.

Real-World Examples

Python: FROM python:3.12-slim AS builder compiles wheels, then runtime copies only wheels+source. Node.js: FROM node:20-alpine AS deps runs npm ci --only=production, then runner copies results. Rust: FROM rust:slim-bookworm AS builder compiles with --release, then copies binary to slim runtime.

Section: Docker 1572 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top