Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes

Write efficient multi-stage Dockerfiles

Learn to write efficient multi-stage Dockerfiles: understand the core concept, step-by-step implementation, hands-on walkthrough, and troubleshooting edge cases. This lesson helps developers produce lean, secure Docker images.

Focus: write efficient multi-stage dockerfiles

Sponsored

If you have ever built a Docker image and stared at the output, wondering why a 10 MB application turned into a 900 MB image, you have felt the pain this lesson solves. Bloated images slow down deployment, increase storage costs, and expose unnecessary attack surfaces. The antidote is the multi-stage Dockerfile — a pattern that lets you compile or bundle your application in one temporary stage and copy only the runtime essentials into a final, lean image.

The problem this lesson solves

Traditional single-stage Dockerfiles often install build tools, compilers, and dependencies that are never needed at runtime. The result: images that are 5 to 10 times larger than they need to be. Consider a Go binary built with FROM golang:1.21. The official Go image is over 800 MB. Your compiled binary might be 15 MB, but you are shipping the entire Go toolchain and OS layer forever. That violates the principle of least privilege and minimal surface for containers.

Multi-stage builds solve this by allowing multiple FROM statements in one Dockerfile. Each FROM starts a new stage. You copy artifacts from earlier stages into later stages — but those earlier stages vanish from the final image. Only what you explicitly copy into the final stage survives.

Core concept / mental model

Think of a multi-stage Dockerfile as an assembly line in a factory. In stage one, you bring in heavy machinery (build tools, compilers, test frameworks) to manufacture the product. In stage two, you discard the machinery and ship only the finished product. The final container image is equivalent to the tidy shipping crate, not the entire factory floor.

Definition

A stage is any FROM line in the Dockerfile. The first stage typically has an alias like AS builder. Build artifacts (compiled binaries, static assets, node_modules) are copied from that builder stage into the final stage using COPY --from=builder.

Key benefit: the layers of the builder stage never appear in the final image history. You get runtime security (no compiler binaries, no package manager caches) and dramatically smaller image sizes.

How it works step by step

  1. Write the first stage — choose a base image with build tools (e.g., golang:1.21 for Go, maven:3.8 for Java, node:18 for Node.js). Install system dependencies, copy source code, compile or bundle the application.
  2. Write the final stage — choose a minimal base image (e.g., alpine, distroless, or scratch). This stage contains only what is needed to run the application.
  3. Copy only the build artifact — use COPY --from=<stage-name> <source-path> <destination-path> to bring over only the compiled binary or runtime directory.
  4. Set the command — define CMD or ENTRYPOINT for the final stage.

Pro tip: Name each stage explicitly with AS. Without an alias, you must reference stages by index (e.g., COPY --from=0), which breaks when you change stage order.

Hands-on walkthrough

Let us build a small Go web server. First, a naive single-stage Dockerfile, then the multi-stage version.

Naive single-stage (bloated)

# app.go -- a simple HTTP server
package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, world!")
    })
    http.ListenAndServe(":8080", nil)
}
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o server .
EXPOSE 8080
CMD ["./server"]

Build it:

docker build -t go-server:naive .
docker images go-server:naive

Expected output (approximate):

REPOSITORY   TAG      IMAGE ID       SIZE
go-server    naive    abcdef123456   874 MB

Efficient multi-stage

# Stage 1: build the binary
FROM golang:1.21 AS builder
WORKDIR /app
# Cache dependencies separately for faster rebuilds
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server .

# Stage 2: minimal runtime
FROM alpine:3.19
# Alpine has no libc, but Go's static binary works fine
RUN apk add --no-cache ca-certificates
WORKDIR /root/
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]

Build and inspect:

docker build -t go-server:multi .
docker images go-server:multi

Expected output (approximate):

REPOSITORY   TAG      IMAGE ID       SIZE
go-server    multi    fedcba654321   12.4 MB

Pro tip: For even smaller images, use FROM scratch for fully static Go binaries compiled with CGO_ENABLED=0. For dynamic binaries, use alpine or distroless.

Compare options / when to choose what

Strategy Size Build speed Security Use case
Single-stage monolithic Large (800 MB+) Fastest for small projects Low — build tools remain Prototypes, internal tools
Multi-stage with Alpine final Small (10–20 MB) Moderate High — only runtime Production services
Multi-stage with scratch final Tiny (5–10 MB) Moderate Highest — zero OS Static binaries, microservices
Distroless final Medium (20–50 MB) Moderate Very high — minimal OS Services needing libc but not package manager

When to choose which:

  • Single-stage: never for production. Only for throw-away containers or tiny shell scripts.
  • Multi-stage + Alpine: default for compiled languages like Go, Rust, or C where you need a few runtime packages (e.g., ca-certificates, tini).
  • Multi-stage + scratch: ideal for fully static binaries (Go with CGO_ENABLED=0, Rust with target-feature flags). Be careful — scratch provides no shell, no apt, no sh.
  • Distroless: good for Python, Java, or Node.js where you need a runtime but want no package manager or shell.

Troubleshooting & edge cases

Error: COPY --from=builder references a stage that does not exist

Step 5/8 : COPY --from=builder /app/server .
The named stage 'builder' is not declared

Fix: ensure the target stage has an AS builder alias. Or use COPY --from=0 but that is fragile.

Error: binary not found or wrong WORKDIR

COPY failed: file not found in build context: /app/server

Fix: 1. Verify the binary is compiled in the correct WORKDIR. 2. Use absolute paths in COPY --from to avoid confusion. 3. RUN ls -la in the builder stage before COPY to confirm the file exists.

Image still large — what did I forget?

Possible causes: - You copied more than the binary (e.g., COPY . . in the final stage).Narrow the copy to only what you need. - You installed packages in both stages.Install runtime packages only in the final stage. - Dependencies are layered in the final stage.*Combine RUN commands with && to minimize layers.

scratch image fails with "no such file or directory"

Static binary might still link against libc. Build with:

CGO_ENABLED=0 go build -o server .

Confirm with ldd server — it should say "not a dynamic executable".

What you learned & what's next

You now understand how to write efficient multi-stage Dockerfiles. You can explain the core concept (multiple FROM stages with COPY --from), apply it in a hands-on Go example, and compare image sizes and security trade-offs.

Key takeaways: - Multi-stage builds produce lean images by discarding build-time dependencies. - Always name each stage with AS for readability and stability. - The final stage should contain only runtime essentials — binary, config files, and minimal OS. - Use scratch, alpine, or distroless for the final stage depending on requirements.

Next step: In the following lesson, you will learn how to optimize docker build caching with proper layer ordering and .dockerignore to accelerate your CI/CD pipelines.

Practice recap

Create a multi-stage Dockerfile for a simple Python Flask app. Use python:3.11-slim as the builder to install dependencies and distroless/python3-debian12 as the final stage. Copy only the application code and installed packages. Compare the image sizes with a naive single-stage version.

Common mistakes

  • Copying entire source code into the final stage (e.g., COPY . .) — always copy only the compiled artifact.
  • Forgetting to name builder stages — using COPY --from=0 works but breaks if you add or reorder stages.
  • Using a huge base image for the runtime stage (e.g., golang:1.21 again) — choose alpine, distroless, or scratch.
  • Building dynamic binaries and then using scratch — the binary silently fails at runtime because it needs libc.

Variations

  1. Use distroless base for Python/Node apps — they provide a runtime but no package manager or shell.
  2. Combine multiple builder stages (e.g., compile frontend in node:18 and backend in golang:1.21, then copy both into final).
  3. Leverage BuildKit cache mounts (--mount=type=cache) inside builder stages to speed up dependency download.

Real-world use cases

  • Compile a Go microservice with full static binary and deploy in a 5 MB scratch-based image.
  • Package a React frontend: build assets in node:18, copy static files to nginx:alpine for serving.
  • Create a Python ML inference image: install dependencies in python:3.11-slim, then copy only the model and entrypoint into distroless.

Key takeaways

  • Multi-stage Dockerfiles use multiple FROM lines to separate build-time and runtime.
  • Name every stage with AS for maintainable and robust COPY --from references.
  • Only copy the final artifact(s) into the last stage — all previous layers are discarded.
  • Choose the runtime base image carefully: scratch for static binaries, alpine for light package needs, distroless for maximum security.
  • Verify binary dependencies with ldd before switching to scratch.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.