Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes

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

Sponsored

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 (or build-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_name instruction copies files from a previous stage's filesystem, even if that stage's base image is completely different (e.g., copying a binary from golang:1.21 into alpine).
  • 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 --from flag only copies files — not environment variables, ENTRYPOINT, or CMD settings.

How it works step by step

  1. Start with a builder stage — Choose a full-featured base image that has the tools you need (e.g., golang:1.21 for Go, maven:3.9 for Java, node:20 for Node.js). Install dependencies, copy source code, and run the build command.

  2. 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).

  3. Copy only the artifacts — Use COPY --from=builder /path/in/builder /path/in/final to copy compiled binaries, static assets, configuration files, or SQL migration scripts. Do not copy source code, node_modules (except production deps), or build caches.

  4. Set runtime metadata — Set EXPOSE, ENV, WORKDIR, ENTRYPOINT, and CMD in the final stage only. These do not leak from previous stages.

  5. 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 deps stage ensures node_modules are exactly what npm ci produces, no leftover dev packages. Alpine's node:20-alpine is ~120 MB vs node:20 at ~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, or go 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=0 for Go binaries when using Alpine or scratch, causing runtime crashes due to musl vs glibc incompatibility.
  • Copying dev dependencies (like node_modules with dev packages) into the production image — always use npm ci --only=production in a separate deps stage.
  • Not giving stages meaningful names (build, test, runtime) — leads to hard-to-read Dockerfiles when you reference --from=0 instead of --from=builder.

Variations

  1. Use a distroless base image (gcr.io/distroless/static) instead of Alpine for even tighter security posture.
  2. Combine multi-stage with Docker BuildKit's --cache-from to reuse remote cache across CI builds.
  3. Use a single RUN instruction 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.21 and deployed as a 10 MB image on scratch — 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 FROM statements 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 .dockerignore to exclude node_modules, target, or other build artifacts from being copied into any stage.

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.