← Back to DevBytes

Docker Multi-Stage Builds: Best Practices and Common Pitfalls

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:

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:

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles