Skip to main content
RunBook Academy

Docker & ContainersV · Dockerfiles & BuildKitMulti-stage

Multi-stage builds — separating build from runtime

Intermediate⏱ ~28 mindocker

What you'll learn

  • Structure a multi-stage Dockerfile
  • Copy artefacts between stages, from external images, and from named contexts
  • Explain why the build stage is absent from the image but present in the build cache
  • Diagnose a binary that was built in one stage and will not execute in another
  • Debug and verify an intermediate stage without shipping it

Prerequisites

Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12

Not yet marked complete on this device.

A multi-stage build lets you compile in a fat image and ship a thin one. The build stage has the toolchain (compilers, package managers, source). The runtime stage has only what the application needs at runtime. The image you ship is the runtime stage.

A complete multi-stage example

# syntax=docker/dockerfile:1

# Stage 1: build
FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app

# Stage 2: runtime
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build --chown=nonroot:nonroot /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]

The final image contains:

  • Distroless base: ~2 MB.
  • The static binary: ~10–30 MB.
  • Nothing else.

Total: ~12–32 MB. The Go toolchain (1+ GB) is gone.

Why multi-stage is the right default

Without multi-stage, the same image is used for build and runtime. That means:

  • The compiler is in the production image. Wasted space and attack surface.
  • Source code may be in the image (depending on COPY/.dockerignore).
  • Build artefacts (node_modules, target/) are in the image.

A multi-stage build is not just smaller; it is a smaller attack surface. The runtime image does not have a compiler, so even if an attacker exploits a vulnerability in the application, they cannot recompile a payload on the host.

Where the build stage actually goes

“The build stage is not in the final image” is true. It is also narrower than people hear it, and the gap is where a real class of disclosure lives.

Named stages

Use AS name to give stages readable names:

FROM node:22 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:22 AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM nginx:1.27 AS runtime
COPY --from=build /app/dist /usr/share/nginx/html

This reads top-to-bottom: deps, build, runtime. The runtime stage is what you ship.

What COPY --from can point at

Per the Dockerfile reference, “the COPY --from flag lets you copy files from an image, a build stage, or a named context instead” of the build context. Four forms are worth knowing:

FormExampleUse
Stage nameCOPY --from=build /out/app /appThe normal case
Stage indexCOPY --from=0 /out/app /appUnnamed stages; avoid, it breaks on reorder
External imageCOPY --from=nginx:1.27 /etc/nginx/mime.types /etc/Lift one file out of a published image
Named contextCOPY --from=docs /site /usr/share/nginx/htmlPaired with --build-context docs=./website

The external-image form is genuinely useful and under-used: it is how you get ca-certificates or a single CLI binary into a distroless image with no package manager.

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=alpine:3.21 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]

--link changes the semantics so that, in Docker’s words, “files remain independent on their own layer and don’t get invalidated when commands on previous layers are changed.”

In a multi-stage build that is worth real time. Without it, bumping the runtime base image invalidates the COPY --from=build that follows it, so the artefact layer is rebuilt and re-pushed even though the artefact is byte-identical. With --link, the artefact layer is reused and only the base layers change.

FROM gcr.io/distroless/static-debian12:nonroot
COPY --link --from=build /out/app /app

The trade-off: a --link copy does not see the destination image’s filesystem, so it cannot resolve a user name for --chown against that image’s /etc/passwd and it will not follow symlinks that exist only in the base. Use numeric IDs with it.

Targeting a specific stage

You can build only one stage of a multi-stage Dockerfile:

docker buildx build --target deps --tag myorg/myapp:deps .

This is useful for CI where you want to share the deps stage across multiple test stages.

BuildKit builds only what the target’s ancestry requires. A stage that nothing references is not in the graph and never executes — which is why you can keep a test stage and a lint stage in the same Dockerfile without paying for them in the production build.

Verifying the artefact before you ship it

The failure above is entirely preventable, in the build stage, with a check that fails the build rather than the container.

FROM golang:1.24 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/app
# Fail here, not at 03:00 in production.
RUN ldd /out/app 2>&1 | grep -q 'not a dynamic executable' \
 || (echo 'FAIL: /out/app is dynamically linked; it will not run on scratch or static-distroless' && exit 1)

ldd on a static binary reports not a dynamic executable; on a dynamic one it lists the loader and the shared objects. That single RUN converts a runtime mystery into a build error with the reason in the message.

For an image you have already built, ask the image itself:

Read-only / Safelayer count
$ docker image inspect --format '{{len .RootFS.Layers}} layers, {{.Size}} bytes' myorg/myapp:1.0.0
3 layers, 14238720 bytes

Illustrative output

Read-only / Safewhat is in there
IMAGE=myorg/myapp:1.0.0
docker save "$IMAGE" | tar -xO --wildcards '*/layer.tar' | tar -tv | head -50

If a compiler, a .git directory, or your source tree appears in that listing, the multi-stage split did not do what you assumed.

Common mistakes

Knowledge check

Knowledge check · 6 questions

  1. Q1. A multi-stage build's primary benefit is:

  2. Q2. BuildKit secrets in a multi-stage build persist into the final image by default.

  3. Q3. The build stage of a multi-stage Dockerfile is not referenced by the final image's manifest. Where do its layers still exist after the build?

  4. Q4. A Go binary built in `golang:1.24` and copied into `alpine:3.21` fails with `exec /app: no such file or directory`, though the file is present and executable. What is the cause?

  5. Q5. Which are valid sources for `COPY --from`? Select all that apply.

  6. Q6. BuildKit never executes a stage that lies outside the build target's ancestry, so unused lint or test stages cost nothing in a production build.

Passing score: 75%. Answers are checked in this browser.