Write Efficient Multi-Stage Dockerfiles
Master writing efficient multi-stage Dockerfiles in this concise Docker tutorial. Learn to reduce image size, improve build speed, and follow best practices through step-by-step exercises and troubleshooting tips.
Focus: write efficient multi-stage dockerfiles
You've been building Docker images, but chances are they're bloated with unnecessary tools, libraries, and layers. When you run docker images, those multi-hundred-MB images stack up fast, slowing down CI/CD pipelines and consuming disk space. Multi-stage builds solve this by letting you use multiple FROM statements in a single Dockerfile, each stage a separate base image, so you can compile in one stage and copy only the production artifacts into the final slim image. This lesson will show you exactly how to write efficient multi-stage Dockerfiles, cutting image sizes by 50–90% without sacrificing build reproducibility.
The problem this lesson solves
Single-stage Dockerfiles mix the build environment with the runtime environment. For a compiled language like Go, C++, or Java, that means your final image includes the compiler, linker, debug symbols, and a full OS update chain — none of which your application needs at runtime.
Pain point: A typical Go binary is ~15 MB, but a single-stage Dockerfile that installs Go, builds, and deploys can produce an image over 800 MB. Multiply that by dozens of microservices, and you're wasting gigabytes of registry storage and minutes of pull time.
Single-stage builds also force you into a trade-off between image size and build complexity. You might manually concatenate RUN commands to reduce layers, or use --no-install-recommends in apt, but the real overhead — the compiler, package manager cache, and test tools — stays. Multi-stage builds remove that trade-off entirely.
Think of the same problem in physical packaging: you wouldn't ship a product inside the assembly line machinery. A multi-stage Dockerfile is like building a chair in a workshop, then carrying only the finished chair to the delivery truck. The workshop stays behind.
Core concept / mental model
A multi-stage Dockerfile is a single file with multiple FROM statements. Each FROM starts a new build stage, and you can selectively copy artifacts from earlier stages into later ones. The final stage becomes your production image.
- Stage names: You can give each stage a name with
FROM ... AS build(orbuild-env,test, etc.) to reference it later with--from=build. Unnamed stages are referenced by index (0, 1, 2…), but named stages are clearer. - Artifact copying: The
COPY --from=stage_nameinstruction copies files from a previous stage's filesystem, even if that stage's base image is completely different (e.g., copying a binary fromgolang:1.21intoalpine). - Image layers: Each stage produces its own set of layers. The final image contains only layers from the last stage. Intermediate stages are cached and reused unless you
docker build --no-cache.
┌─────────────────────┐ ┌──────────────────────┐
│ Stage 0: builder │ │ Stage 1: production │
│ FROM golang:1.21 │ │ FROM alpine:3.18 │
│ COPY . /src │ │ COPY --from=0 │
│ RUN go build -o /bin/myapp │ /bin/myapp . │
│ (fat image: 900MB) │ │ (slim image: 15MB) │
└─────────────────────┘ └──────────────────────┘
│ │
└───── copy binary ──────┘
Pro tip: Stages are completely isolated. You can use different base images, different package managers, even different distros. The
--fromflag only copies files — not environment variables, ENTRYPOINT, or CMD settings.
How it works step by step
-
Start with a builder stage — Choose a full-featured base image that has the tools you need (e.g.,
golang:1.21for Go,maven:3.9for Java,node:20for Node.js). Install dependencies, copy source code, and run the build command. -
Select the final runtime image — Pick a minimal base image. Alpine Linux (5 MB) is popular for compiled binaries. For interpreted languages, use the official slim variants (
python:3.11-slim,node:20-alpine). -
Copy only the artifacts — Use
COPY --from=builder /path/in/builder /path/in/finalto copy compiled binaries, static assets, configuration files, or SQL migration scripts. Do not copy source code, node_modules (except production deps), or build caches. -
Set runtime metadata — Set
EXPOSE,ENV,WORKDIR,ENTRYPOINT, andCMDin the final stage only. These do not leak from previous stages. -
Remove builder stage (automatic) — Docker discards all intermediate layers from previous stages during the final image assembly. Only the last stage's layers are tagged and pushed.
# Stage 1: Build the Go binary
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/myapp .
# Stage 2: Create the runtime image
FROM alpine:3.18
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /app/myapp /usr/local/bin/myapp
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/myapp"]
Why this matters: The builder stage is 900+ MB. The final alpine stage is ~10 MB + the binary (15 MB) = 25 MB total. That's a 97% reduction.
Hands-on walkthrough
Example 1: Go static binary with zero dependencies
Create a file named Dockerfile.gostatic:
# syntax=docker/dockerfile:1.4
FROM golang:1.21-alpine AS builder
RUN apk add --no-cache gcc musl-dev
WORKDIR /src
COPY . .
RUN go build -ldflags="-s -w" -o /mybin .
FROM scratch
COPY --from=builder /mybin /mybin
ENTRYPOINT ["/mybin"]
Build and check the size:
docker build -f Dockerfile.gostatic -t myapp:multistage .
docker images myapp:multistage
# Expected output: ~12 MB
Example 2: Node.js with production dependencies only
For JavaScript apps, you can split dependencies into development and production, then copy only the production node_modules into the final slim image:
# syntax=docker/dockerfile:1.4
FROM node:20 AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine
WORKDIR /app
COPY --from=deps /app/node_modules /app/node_modules
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]
Why not just copy package.json? Copying from a
depsstage ensuresnode_modulesare exactly whatnpm ciproduces, no leftover dev packages. Alpine'snode:20-alpineis ~120 MB vsnode:20at ~350 MB.
Example 3: Multi-language stack (Python + C extension)
Some projects compile C extensions in a builder stage, then run Python in the final image:
FROM python:3.11-slim AS builder
RUN apt-get update && apt-get install -y gcc libffi-dev
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.11-slim
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --find-links=/wheels -r /wheels/*.whl
COPY . /app
WORKDIR /app
CMD ["python", "app.py"]
This pattern keeps the build tools (gcc, libffi-dev) out of the final image. The builder stage is 400 MB; the final image is ~200 MB.
Compare options / when to choose what
| Approach | Image size | Build speed | Complexity | Best for |
|---|---|---|---|---|
| Single-stage minimal | Medium (depends on base) | Fastest | Low | Simple scripts, rapid prototyping |
| Single-stage with cleanup | Large (still includes toolchain) | Medium | Medium | When you can't use multi-stage (e.g., no builder stage) |
| Multi-stage two-phase | Very small | Fast (cached builder) | Medium | Compiled languages (Go, Rust, C) |
| Multi-stage multi-phase | Variable | Slower (multiple build stages) | High | Complex pipelines (e.g., test → lint → build → runtime) |
When to choose multi-stage: - Your application compiles or bundles from source - You want < 100 MB images for microservices - You need to separate development dependencies from production runtime
When to stay single-stage:
- Your base image is already < 50 MB and your app is interpreted (e.g., Python Flask with few deps)
- You're iterating rapidly and don't want the cognitive overhead
- The build itself is trivial (e.g., COPY . && RUN pip install)
Decision rule: If your Dockerfile has
apt-get install build-essential,gcc,npm install --dev, orgo build, you should be using multi-stage.
Troubleshooting & edge cases
Error: "COPY failed: stat /var/lib/docker/...: file does not exist"
- Cause: The artifact path in the builder stage is wrong, or the file wasn't created because the build command failed.
- Fix: Run docker build --no-cache and check builder logs. Add RUN ls -la /path/to/artifact in the builder stage temporarily.
Error: "cannot find package" during go build in builder stage
- Cause: The builder stage's WORKDIR or COPY layer doesn't include go.mod before downloading modules.
- Fix: Order the Dockerfile as shown in Example 1 — first copy go.mod and go.sum, run go mod download, then copy all source. This caches the module download layer.
Pitfall: Copying entire node_modules instead of production subset
- If you COPY . . in the final stage after copying node_modules from deps, you'll overwrite them with local folder contents (which may include dev deps).
- Fix: Copy node_modules last, or use a .dockerignore file to exclude node_modules from COPY . ..
Edge case: CGO_ENABLED=0 is not the default
- Go's CGO_ENABLED=0 produces a fully static binary that runs on scratch or alpine. Without it, the binary links against glibc and may fail on Alpine (musl).
- Fix: Always set CGO_ENABLED=0 for Go, especially when targeting Alpine or scratch.
Builder stage grows unexpectedly
- Each builder layer is cached by Docker. If you frequently change source files but not dependencies, the builder stage still executes the full build on each change, making the build slower.
- Fix: Separate dependency installation (RUN go mod download) from source copy and build (COPY . && RUN go build). This way, dependency installation is cached across builds.
What you learned & what's next
You now understand how to write efficient multi-stage Dockerfiles. You can: - Explain why multi-stage builds reduce image size - Choose between single-stage and multi-stage based on project needs - Set up a builder stage for compiling code and a minimal runtime stage - Avoid common pitfalls like wrong paths or missing CGO flags
The next step in the pipeline is to orchestrate multiple containers with Docker Compose, where each service can be built from its own multi-stage Dockerfile. You'll connect a database, a backend, and a frontend in a single YAML file, all while keeping images lean.
Practice recap
Create a new directory and put a simple Go "Hello, world!" program in main.go. Write a multi-stage Dockerfile that builds the binary in a golang:1.21 stage, then copies it to scratch. Run docker build and verify the image size is under 20 MB. Next, add a .dockerignore file to exclude any local vendor folder if you use one.
Common mistakes
- Copying entire source code into the final stage instead of just the compiled binary — bloats the runtime image with unnecessary files.
- Forgetting to set
CGO_ENABLED=0for Go binaries when using Alpine or scratch, causing runtime crashes due to musl vs glibc incompatibility. - Copying dev dependencies (like
node_moduleswith dev packages) into the production image — always usenpm ci --only=productionin a separate deps stage. - Not giving stages meaningful names (
build,test,runtime) — leads to hard-to-read Dockerfiles when you reference--from=0instead of--from=builder.
Variations
- Use a distroless base image (gcr.io/distroless/static) instead of Alpine for even tighter security posture.
- Combine multi-stage with Docker BuildKit's
--cache-fromto reuse remote cache across CI builds. - Use a single
RUNinstruction with heredoc for complex build scripts, reducing layer count while keeping readability.
Real-world use cases
- A Go microservice for a payment API is built with
golang:1.21and deployed as a 10 MB image onscratch— CI/CD pipeline times drop from 5 minutes to 45 seconds. - A Node.js web app uses three stages: dependencies (npm ci), assets (webpack build), and runtime (node:20-alpine) — reduces image from 800 MB to 120 MB.
- A Python ML model serving app compiles Cython extensions in a builder stage, then runs on
python:3.11-slim— final image is 250 MB vs 1.2 GB.
Key takeaways
- Multi-stage builds use multiple
FROMstatements to separate build dependencies from runtime artifacts. - The final image contains only files explicitly copied into the last stage — everything else is discarded.
- Always name your stages (e.g.,
AS builder) for readability and maintainability. - CGO_ENABLED=0 produces static binaries for Go that work on scratch and Alpine.
- Separate dependency installation from source code copy to leverage Docker layer caching.
- Use
.dockerignoreto excludenode_modules,target, or other build artifacts from being copied into any stage.
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.