DevTools Hub

Search tools

Search for a developer tool

How to Write a Multi-Stage Dockerfile

Part of the Docker Toolkit
Pattern
FROM golang:1.23 AS builder ... FROM scratch COPY --from=builder /app /app

two FROM lines — the first builds, the second becomes the final image; only what's explicitly COPY --from=builder survives into it

Explanation

A multi-stage Dockerfile has more than one FROM instruction, each one starting a new, independent build stage. Only the last stage becomes the final image — everything from an earlier stage has to be explicitly pulled forward with COPY --from=, or it stays behind entirely. That one rule is the whole mechanism: a build stage can have an entire compiler toolchain, source tree, and test suite in it, and none of that has to ship, as long as the final stage only copies the specific output it actually needs.

Naming a stage with AS

FROM golang:1.23 AS builder gives that stage the name builder, which later stages reference with COPY --from=builder .... Without a name, stages can only be referenced by their numeric index (--from=0), which breaks the moment a stage gets inserted or reordered — naming every stage is worth doing even when there are only two.

Stage order in the file doesn't have to match the final dependency order

A stage can reference any earlier stage by name, including chaining one stage directly from another with FROM builder AS test. This is how a dedicated test stage gets built on top of the same compiled output the final image will use, without recompiling anything or affecting what ships — since nothing later does FROM test or COPY --from=test, that stage's contents simply never reach the final image.

What actually ends up in the final image

Only the last FROM and whatever it explicitly copies forward. A Go binary built with CGO_ENABLED=0 has no dynamic dependencies at all, which is why FROM scratch — a literally empty base image — is a valid, common final stage for one: there's nothing else the binary needs at runtime. A Node app still needs a runtime to execute JavaScript, so its final stage is a slim runtime image plus the compiled output, not scratch.

Each stage caches independently

Docker's build cache tracks every stage on its own — a stage that has to rebuild doesn't force any other stage to rebuild unless something later actually depends on its output. See Docker Layer Caching Explained for how that caching works instruction by instruction, including the classic mistake that defeats it.

Valid examples

  • FROM golang:1.23 AS builder ... RUN CGO_ENABLED=0 go build -o /app ... FROM scratch COPY --from=builder /app /app

    A static Go binary needs nothing at runtime — scratch (a literally empty base image) is valid precisely because CGO_ENABLED=0 removes every dynamic dependency.

  • FROM node:20 AS deps ... RUN npm ci ... FROM deps AS build ... RUN npm run build ... FROM node:20-slim COPY --from=build /app/dist ./dist

    Chaining stages (FROM deps AS build) reuses deps' already-installed node_modules without rerunning npm ci — each stage only rebuilds if its own instructions or inputs changed.

  • FROM builder AS test ... RUN go test ./... ... FROM scratch AS final COPY --from=builder /app /app

    A dedicated test stage runs the suite during the build — if it fails, the build fails, and since nothing FROM's it, the test stage's contents never reach the final image.

Invalid examples

  • FROM golang:1.23 ... WORKDIR /src ... COPY . . ... RUN go build -o /app ... CMD ["/app"]

    No second FROM — the final image still contains the entire Go toolchain and source tree, often 800MB+ for a binary that's a few MB on its own.

  • FROM node:20 AS build ... FROM node:20-slim COPY --from=builder /app/dist ./dist

    References --from=builder, but the earlier stage was named build, not builder — this fails at build time with "stage not found", not silently.

  • FROM node:20 AS build ... COPY . . ... FROM node:20 COPY . . COPY --from=build /app/dist ./dist

    COPY . . in the final stage re-adds the entire source tree and dev dependencies anyway, undoing the size benefit multi-stage was supposed to provide.

Try it now