Multi-Stage Builds
Summary
- A multi-stage build uses several
FROMstages in one Dockerfile so you compile in a heavy builder and ship only artifacts in a lean final image. - Insight: Build tools, source, and caches never reach production because only what you
COPY --fromsurvives. - Key Concepts: named stages,
COPY --from, build target, final stage, artifact-only runtime. - When to Use: Any compiled language, any app with a build step (bundlers, transpilers), and anywhere you want a small, low-CVE runtime image.
- Limitations: More stages add build complexity, and glibc-linked binaries still need a compatible runtime base.
Recipe
Split the Dockerfile into a builder stage and a runtime stage. Compile in the builder, then copy the finished artifact into a minimal final stage.
docker build -t api:1.0 .That single command runs both stages; BuildKit skips any stage the final target does not depend on.
Working Example
A Go service compiled in golang, then shipped on distroless.
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/api ./cmd/api
FROM gcr.io/distroless/static:nonroot AS final
COPY --from=build /out/api /api
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/api"]The golang builder is roughly 800 MB with a full toolchain. The final image carries only the static binary and a minimal base, so it lands near 10-20 MB.
A Node example follows the same shape - install and build in one stage, copy dist and production dependencies into a slim runtime.
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS final
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]Deep Dive
Named stages and COPY --from
Each FROM ... AS <name> starts a fresh stage with its own filesystem. COPY --from=<name> pulls specific paths forward, so nothing else in that stage ships.
You can also copy from an external image, not just a prior stage. COPY --from=busybox:1.36 /bin/busybox /bin/busybox grabs a file straight from a registry image.
Build targets
Name a stage as a target to build only up to it. This is how you keep test and lint stages in the same file without shipping them.
docker build --target build -t api:test .CI can build the build target to run tests, then build the default final target for the release image, reusing all cached layers.
Cache and ordering
Copy dependency manifests before source in the builder, exactly as in a single-stage build. The dependency install layer stays cached until the manifests change.
Stages build in parallel when they are independent, so BuildKit can compile a front-end and a back-end stage at the same time before a final stage assembles both.
Choosing the final base
The final base decides your CVE surface and image size. scratch ships nothing, distroless ships a minimal userland with CA certs and a nonroot user, and -slim variants keep a shell for debugging.
Parallelism and shared dependency stages
Independent stages build concurrently, so BuildKit can compile several artifacts at once before a final stage assembles them. This shortens wall-clock build time on multi-core CI runners at no extra cost.
A common pattern is a shared deps stage that installs dependencies once, then multiple stages that COPY --from=deps. It keeps the dependency install cached and reused across a test stage and the release stage.
Gotchas
Static linking matters for scratch and distroless-static. A Go binary needs CGO_ENABLED=0, and a Rust binary often needs the musl target, or it will fail to start on a base with no glibc.
Copying whole node_modules from a dev install ships dev dependencies. Install with npm ci --omit=dev in the runtime path, or prune before copying.
Forgetting USER in the final stage leaves the app as root even though the builder dropped privileges. Set the runtime user in the final stage explicitly.
TLS calls fail on scratch because there are no CA certificates. Copy /etc/ssl/certs/ca-certificates.crt from the builder or use distroless, which includes them.
Debugging a distroless final image is hard because there is no shell. Keep a debug stage or use kubectl debug with an ephemeral container instead of adding a shell to production.
Alternatives
Single-stage slim images are simpler and fine for interpreted apps with no build step, but they carry package managers and build caches into production.
Building outside Docker and copying a prebuilt artifact into a runtime-only Dockerfile works, but it splits your build logic across two systems and loses BuildKit caching.
Buildpacks (pack) produce optimized images without a Dockerfile and are convenient for standardized platforms, at the cost of less control over the exact layers.
Bazel or Nix container rules give reproducible, fine-grained images for large monorepos, but carry a steep learning curve compared with a multi-stage Dockerfile.
FAQs
Do unused stages slow my build? No. BuildKit builds only the stages the target depends on, so a test stage is skipped when you build the release target.
Can I copy from an image that is not a stage? Yes. COPY --from=alpine:3.20 /path /path copies directly from any image reference.
Why does my static binary crash on scratch? It is probably dynamically linked. Build with CGO_ENABLED=0 for Go or a musl target for Rust.
How do I run tests inside the build? Add a stage that runs the test command and build it with --target, so a failing test fails the build.
Is a multi-stage image slower to pull? No, it is smaller and faster to pull, since only the final stage's layers are part of the published image.
Can stages share a cache? Yes. Layers are cached per stage, and independent stages can build in parallel to shorten total build time.
Related
- How a Dockerfile Becomes an Image
- How Image Layers & Union Filesystems Work
- Distroless & Minimal Bases
- BuildKit & Cache Mounts
- Dockerfiles Best Practices
Stack versions: This page was written for Kubernetes 1.36.2, Docker Engine 29.6.1 (BuildKit default), containerd (CRI runtime on nodes), Helm 3, Compose v2, Argo CD (latest - verify at build), and Gateway API (GA - verify controller support at build).