What is a Docker Multi-Stage Build?
Multi-stage builds are a Docker feature introduced in version 17.05 that allow you to use multiple FROM statements in a single Dockerfile. Each FROM instruction begins a new stage of the build, and you can selectively copy artifacts from one stage to another, leaving behind everything you don't need in the final image. The result is a lean, production-ready container image that contains only the runtime dependencies and compiled outputâwithout the bloat of build tools, SDKs, or intermediate files.
Before multi-stage builds, developers resorted to awkward workarounds like "builder" patterns with shell scripts, separate Dockerfiles for build and runtime, or manually chaining docker run commands to extract artifacts. Multi-stage builds solve this elegantly within a single Dockerfile, making builds reproducible, simpler to maintain, and fully self-documented.
Why Multi-Stage Builds Matter
đ Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →The core problem multi-stage builds solve is image size bloat. Build dependenciesâcompilers, package managers, header files, test frameworksâoften consume hundreds of megabytes yet serve no purpose at runtime. A bloated image means:
- Slower deployments: Larger images take longer to pull across the network, slowing down CI/CD pipelines and rollouts.
- Higher attack surface: Build tools and unused libraries contain vulnerabilities that remain in the image unnecessarily.
- Wasted storage and bandwidth: Container registries charge by storage and data transfer; oversized images cost real money at scale.
- Slower cold starts: In orchestration environments like Kubernetes, pulling a multi-gigabyte image before a pod can start adds latency.
By stripping away build-time cruft, multi-stage builds routinely reduce final image sizes by 60â90%. A Go application that might weigh 800 MB with the full Go toolchain can shrink to under 10 MB when only the statically compiled binary is copied into a minimal scratch or alpine image.
How Multi-Stage Builds Work
Basic Syntax
A multi-stage Dockerfile looks like this in its simplest form:
# Stage 1: Build the application
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server .
# Stage 2: Create the runtime image
FROM alpine:3.19
COPY --from=builder /app/server /usr/local/bin/server
EXPOSE 8080
CMD ["server"]
Key elements to notice:
AS buildergives the first stage a name. You can reference it later with--from=builder.- The second
FROMresets the filesystem completely. Nothing from stage 1 persists except what you explicitly copy. COPY --from=builderpulls only the compiled binary from the first stage into the final image.
A More Complete Example: Node.js Application
Here's a realistic example for a Node.js backend. We want to run npm ci and potentially a TypeScript compilation step, but the final image should contain only production dependencies and the compiled JavaScript:
# Stage 1: Install all dependencies and build
FROM node:20-alpine AS builder
WORKDIR /app
# Copy dependency manifests
COPY package.json package-lock.json ./
RUN npm ci
# Copy source code and compile TypeScript
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
# Stage 2: Production dependencies only
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Stage 3: Final runtime image
FROM node:20-alpine
WORKDIR /app
# Copy production node_modules from deps stage
COPY --from=deps /app/node_modules ./node_modules
# Copy compiled output from builder stage
COPY --from=builder /app/dist ./dist
# Copy package.json for runtime metadata if needed
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]
This three-stage approach separates concerns cleanly: building, dependency resolution, and runtime. The final image excludes node_modules for development-only packages, source TypeScript files, and the TypeScript compiler itselfâoften saving 100â200 MB or more.
Best Practices
1. Name Your Stages Explicitly
Always use the AS keyword to give stages meaningful names. While you can reference stages by their zero-based index (--from=0), named stages are far more readable and resilient to reordering:
# Good: self-documenting
FROM python:3.12 AS builder
# ...
COPY --from=builder /app/dist ./
# Avoid: fragile and cryptic
FROM python:3.12
# ...
COPY --from=0 /app/dist ./
2. Choose the Right Base Image for Each Stage
Different stages benefit from different base images. The build stage should use a fat image with all the tools you need; the runtime stage should use the slimmest image possible:
# Build stage: full SDK
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS builder
# Runtime stage: ASP.NET runtime only, no SDK
FROM mcr.microsoft.com/dotnet/aspnet:8.0
COPY --from=builder /app/out ./
ENTRYPOINT ["dotnet", "MyApp.dll"]
For Go applications, consider scratch (an empty base image) as the final stage if your binary is truly static. For interpreted languages, use alpine or slim variants of the official images.
3. Leverage the Build Cache with Intentional Layer Ordering
Docker caches layers based on the instruction and the files copied. To maximize cache hits, copy dependency manifests first, then install dependencies, then copy application code. This way, changes to your source code don't invalidate the cached layer for dependency installation:
FROM node:20-alpine AS builder
WORKDIR /app
# Step 1: Copy only dependency files (cached unless they change)
COPY package.json package-lock.json ./
RUN npm ci
# Step 2: Copy source code (invalidates only this layer and below)
COPY src/ ./src/
RUN npm run build
If you copy everything at once (COPY . .), any change to any file triggers a full re-install of dependencies, wasting time.
4. Copy Only What You Truly Need
Be surgical with COPY --from=.... Avoid copying entire directories if you only need a specific artifact. Use .dockerignore in the build context to exclude unnecessary files from even entering the build stage:
# .dockerignore
node_modules
.git
*.md
.env*
test/
coverage/
Dockerfile
docker-compose.yml
Then in the final stage, copy precisely:
# Copy only the compiled binary, not the entire /app directory
COPY --from=builder /app/bin/server /usr/local/bin/server
# For web apps, copy the built static assets directory
COPY --from=builder /app/dist ./public
5. Combine RUN Instructions to Reduce Layers
Each RUN, COPY, and ADD instruction creates a new layer. While layers are cheap, excessive layering adds minor overhead. More importantly, cleaning up temporary files within the same RUN ensures they don't persist in the layer:
# Good: clean up in the same RUN so temp files don't become a permanent layer
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential curl && \
# ... build steps ... && \
apt-get purge -y build-essential curl && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
# Less optimal: cleanup in a separate RUN leaves the installed packages
# in an intermediate layer that still contributes to final image size
RUN apt-get update && apt-get install -y build-essential
RUN make build
RUN apt-get purge -y build-essential
6. Use a Dedicated Stage for Sensitive Operations
If your build requires secrets (API keys, tokens, private certificates), perform those operations in an intermediate stage. Secrets embedded in layers of an intermediate stage never reach the final image:
FROM alpine:3.19 AS secrets-stage
ARG PRIVATE_KEY
RUN mkdir -p /root/.ssh && \
echo "$PRIVATE_KEY" > /root/.ssh/id_rsa && \
chmod 600 /root/.ssh/id_rsa
RUN git clone git@github.com:private/repo.git
FROM node:20-alpine AS builder
COPY --from=secrets-stage /repo ./repo
RUN cd repo && npm ci && npm run build
FROM node:20-alpine
COPY --from=builder /repo/dist ./dist
# The private key never exists in this final image
CMD ["node", "dist/index.js"]
Even better, use Docker BuildKit's --mount=type=secret feature, which mounts secrets only for the duration of a single RUN instruction without persisting them in any layer.
7. Minimize the Number of Stages
While stages are powerful, don't create unnecessary ones. Two or three stages cover most use cases: one for building, optionally one for dependency resolution, and one for runtime. Each additional stage adds complexity without benefit unless it serves a clear purpose like isolating secrets or parallelizing independent steps.
Common Pitfalls
Pitfall 1: Forgetting to Copy Runtime Dependencies
When you copy a compiled binary, it may still depend on dynamic libraries present in the build image but absent from the minimal runtime image. This causes mysterious "file not found" or "exec format error" messages:
# A Go binary compiled with CGO_ENABLED=0 works fine in scratch
FROM golang:1.22 AS builder
RUN CGO_ENABLED=0 go build -o server .
FROM scratch
COPY --from=builder /app/server /server
CMD ["/server"] # Works: static binary
# But if CGO is enabled, the binary links against libc
FROM golang:1.22 AS builder
RUN go build -o server . # CGO_ENABLED=1 by default
FROM alpine:3.19
COPY --from=builder /app/server /usr/local/bin/server
CMD ["server"] # May fail without proper libc in alpine
Always test your final image thoroughly. If you see errors, verify that the runtime base image includes all required shared libraries, or compile statically where possible.
Pitfall 2: Accidentally Including Large Artifacts
A common mistake is copying more than intended. For instance, using COPY --from=builder /app/ /app/ brings along node_modules with dev dependencies, test files, caches, and intermediate build artifacts. Always specify exact paths:
# Too broadâcopies everything including build tool binaries and temp files
COPY --from=builder /app/ /app/
# Preciseâonly what's needed at runtime
COPY --from=builder /app/dist/ ./dist/
COPY --from=deps /app/node_modules/ ./node_modules/
Pitfall 3: Cache Invalidation from Unstable Steps
Instructions like RUN apt-get update or RUN curl https://example.com/latest.tar.gz fetch external resources that change over time. These break cache determinism and can cause builds to behave differently day-to-day. Pin versions explicitly:
# Unstable cache: latest changes over time
RUN curl -O https://example.com/latest.tar.gz
# Stable: pin a specific version
RUN curl -O https://example.com/release/v2.3.1.tar.gz
For system packages, consider using --no-cache flags carefully and pinning to specific image digests in CI environments.
Pitfall 4: Assuming Stages Are Isolated When They Share the Build Cache
Stages within a single Dockerfile share the same build cache. A cache miss in stage 1 can cause stage 2 to rebuild as well if it depends on the output. This is usually desirable but can surprise you when you're debugging. Use docker build --no-cache sparinglyâit invalidates the entire cache across all stages.
Pitfall 5: Not Using BuildKit Features
The legacy Docker builder (pre-BuildKit) works with multi-stage builds but lacks advanced features like --mount=type=cache for persisting package caches between builds. Always enable BuildKit (now the default in Docker 23+) and take advantage of cache mounts to speed up iterative builds:
# Requires BuildKit; speeds up npm ci dramatically
RUN --mount=type=cache,target=/root/.npm \
npm ci
Without cache mounts, every npm ci or pip install downloads packages anew, even when only your application code changed.
Pitfall 6: Neglecting the .dockerignore File
Your build contextâthe set of files sent to the Docker daemonâcan be enormous if you include node_modules, .git, IDE files, logs, and local configuration. A missing or incomplete .dockerignore bloats the build context, slows down the initial COPY step, and can even cause sensitive local files to leak into build stages. Always audit your .dockerignore before pushing images to a registry.
Pitfall 7: Overlooking Image Size After the Build
It's easy to assume multi-stage builds guarantee a small image, but you must verify. Use docker image ls or tools like dive to inspect layer sizes. Sometimes a single overlooked glob pattern or a missed --omit=dev flag can add hundreds of megabytes:
# Check the final image size
docker build -t myapp:latest .
docker image ls myapp:latest
# Dive into layer analysis
dive myapp:latest
Conclusion
Docker multi-stage builds transform how we think about container imagesâfrom monolithic snapshots of an entire build environment to carefully curated, minimal runtime artifacts. By separating the concerns of building and running, you gain faster deployments, smaller attack surfaces, lower storage costs, and cleaner Dockerfiles that serve as executable documentation. The key takeaways are straightforward: name your stages, choose the right base image for each, order layers to maximize caching, copy only what you need, and always validate the final image size. Watch out for common traps like missing runtime dependencies, overly broad COPY instructions, and neglected .dockerignore files. With these practices in mind, multi-stage builds become not just a tool but a habit that elevates the quality and security of every container you ship.