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
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
- Write the first stage — choose a base image with build tools (e.g.,
golang:1.21for Go,maven:3.8for Java,node:18for Node.js). Install system dependencies, copy source code, compile or bundle the application. - Write the final stage — choose a minimal base image (e.g.,
alpine,distroless, orscratch). This stage contains only what is needed to run the application. - Copy only the build artifact — use
COPY --from=<stage-name> <source-path> <destination-path>to bring over only the compiled binary or runtime directory. - Set the command — define
CMDorENTRYPOINTfor 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 scratchfor fully static Go binaries compiled withCGO_ENABLED=0. For dynamic binaries, usealpineordistroless.
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 withtarget-featureflags). Be careful — scratch provides no shell, noapt, nosh. - 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=0works but breaks if you add or reorder stages. - Using a huge base image for the runtime stage (e.g.,
golang:1.21again) — choosealpine,distroless, orscratch. - Building dynamic binaries and then using
scratch— the binary silently fails at runtime because it needs libc.
Variations
- Use
distrolessbase for Python/Node apps — they provide a runtime but no package manager or shell. - Combine multiple builder stages (e.g., compile frontend in
node:18and backend ingolang:1.21, then copy both into final). - 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 tonginx:alpinefor serving. - Create a Python ML inference image: install dependencies in
python:3.11-slim, then copy only the model and entrypoint intodistroless.
Key takeaways
- Multi-stage Dockerfiles use multiple
FROMlines to separate build-time and runtime. - Name every stage with
ASfor maintainable and robustCOPY --fromreferences. - Only copy the final artifact(s) into the last stage — all previous layers are discarded.
- Choose the runtime base image carefully:
scratchfor static binaries,alpinefor light package needs,distrolessfor maximum security. - Verify binary dependencies with
lddbefore switching toscratch.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.