How to Write a Multi-Stage Dockerfile
Part of the Docker ToolkitFROM golang:1.23 AS builder ... FROM scratch COPY --from=builder /app /apptwo 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 /appA 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 ./distChaining 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 /appA 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 ./distReferences --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 ./distCOPY . . in the final stage re-adds the entire source tree and dev dependencies anyway, undoing the size benefit multi-stage was supposed to provide.