A naive Dockerfile ships everything it touched to build the app: the compiler, the package manager, dev headers, build caches, and often a copy of your source. None of that is needed to run the thing. It just sits in the image, inflating the download, widening the attack surface, and occasionally leaking a credential that crept into a layer.
Multi-stage builds fix this without a separate build script or a pile of cleanup commands. You build in one stage, then copy only the finished artifact into a clean final stage. Everything from the build stage — tools, caches, intermediate files — gets left behind.
This guide shows how the pattern works, the exact syntax for copying between stages, and the real-world details that decide whether your final image is 12 MB or 1.2 GB.
The problem with a single-stage build
Here’s a typical first attempt at containerizing a Go service:
FROM golang:1.23
WORKDIR /src
COPY . .
RUN go build -o /app ./cmd/server
CMD ["/app"]
It works. It also produces an image well over 800 MB, because the golang base image carries the entire Go toolchain — compiler, standard library sources, git, the lot. Your service is a single static binary that might be 15 MB. The other 785 MB is build machinery you’ll never run in production.
The same shape shows up in Node, Python, Java, Rust, and C builds. You need a big toolchain to compile or install, but a small runtime to execute. A single stage forces both into one image.
How multi-stage builds work
A multi-stage Dockerfile uses more than one FROM. Each FROM starts a fresh stage with its own base image and its own filesystem. You name a stage with AS <name>, and in a later stage you pull files out of an earlier one with COPY --from=<name>.
Here’s the Go build done properly:
# --- build stage ---
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
# --- final stage ---
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
The first stage compiles the binary using the full Go image. The second stage starts from a tiny distroless base and copies in only /app. When the build finishes, Docker tags only the last stage. The golang layers are discarded. The result is an image a few megabytes in size that contains a binary and almost nothing else.
What ends up in each stage
| Build stage (golang:1.23) | Compiler, go modules cache, source code, intermediate objects — all thrown away. |
|---|---|
| Final stage (distroless/static) | Just the compiled binary and a minimal runtime. This is what ships. |
| COPY --from=build /app /app | The one line that moves the artifact across the boundary. |
Copying between stages: the details
COPY --from is the core of the pattern, and it’s more flexible than it first looks.
You can reference a stage by its AS name:
COPY --from=build /src/dist /usr/share/nginx/html
You can reference a stage by its numeric index (0 is the first FROM), though names are clearer and survive reordering:
COPY --from=0 /src/dist /usr/share/nginx/html
And you can copy straight out of an external image without ever running it — useful for grabbing a CA bundle, a static tool, or config from a published image:
COPY --from=nginx:1.27 /etc/nginx/nginx.conf /etc/nginx/nginx.conf
A Node front-end build shows the pattern with an interpreted-then-served workflow:
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
The node:22 image with node_modules is heavy. The thing you actually serve is a folder of static files. The final image is just Nginx plus those files — the entire Node toolchain stays in the discarded build stage. If you serve apps behind a proxy, the Nginx reverse proxy guide pairs naturally with this.
Choosing a small, safe final base
The build stage base barely matters — it’s discarded. The final stage base is what you ship, so it’s worth choosing deliberately. There’s a rough trade-off between size and convenience:
Common final-stage bases
| distroless (gcr.io/distroless/*) | Smallest and safest. No shell, no package manager. Great for static binaries; harder to debug inside the container. |
|---|---|
| alpine | Tiny (~5 MB) with a shell and apk. Watch for musl libc differences vs glibc — some prebuilt binaries won't run. |
| debian:bookworm-slim | Larger but glibc-based and familiar. A safe default when alpine causes library headaches. |
| scratch | Completely empty. Only works for fully static binaries with no dynamic dependencies at all. |
For a static Go or Rust binary, scratch or distroless/static is ideal. For a Python or Node app that needs an interpreter, a -slim or alpine variant of the language image as the final base is the practical choice — you still drop build tools, even if the runtime isn’t microscopic.
Build a single stage with —target
You don’t always want the final image. During CI you might want to stop at the build stage to run tests, or inspect an intermediate stage while debugging. --target builds up to a named stage and stops:
# Build only up to the 'build' stage
docker build --target build -t myapp:build .
You can also add a dedicated test stage that only runs in CI and never ships:
FROM build AS test
RUN go test ./...
# Run tests as part of the build; fails the build if tests fail
docker build --target test .
Because the test stage isn’t on the path to the final image, a normal docker build skips it entirely. BuildKit only builds the stages that feed the target you ask for.
Keeping secrets out of the image
Multi-stage builds reduce secret exposure as a side effect: a token used only in the build stage isn’t present in the final stage’s layers. But this isn’t a complete defense, and it’s a common source of leaks.
The trap is baking a secret into any layer:
# DON'T DO THIS — the token is permanently recorded in the layer history
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
Even in a build stage, anyone with the image can extract that value from the build history. The correct tool is a BuildKit secret mount, which exposes the secret to a single RUN and never writes it to a layer:
RUN --mount=type=secret,id=npmtoken \
NPM_TOKEN=$(cat /run/secrets/npmtoken) npm ci
docker build --secret id=npmtoken,env=NPM_TOKEN -t myapp .
The token is available during that npm ci and gone afterward — nothing lands in the image. For the broader picture on hardening images, see the Docker security best practices guide.
A checklist before you ship
Multi-stage build review
- Build tools live in an early stage, not the final image
- The final stage copies only the artifact (binary, dist folder, jar)
- Dependencies are installed before source is copied, for layer caching
- Final base is slim/alpine/distroless, not the full SDK image
- Container runs as a non-root USER
- No secrets in ARG, ENV, or COPY — use --mount=type=secret
- A .dockerignore excludes .git, node_modules, and local junk from the build context
Don’t overlook that last point. A .dockerignore keeps your .git directory, local node_modules, and build output from being sent to the daemon as build context. It speeds up builds and prevents accidentally copying stale or sensitive files into a stage.
Wrapping up
Multi-stage builds come down to one idea: separate the environment that builds your app from the environment that runs it. Compile in a fat stage with every tool you need, then COPY --from just the finished artifact into a clean, minimal final stage. The compilers, caches, and dev dependencies never reach production.
Start by splitting one existing Dockerfile into a build stage and a slim final stage, pick a sensible final base, and confirm the size drop with docker images. From there, layer in --target for CI tests and secret mounts for build-time credentials. If you’re still wiring up how images, containers, and volumes relate, the images vs containers vs volumes explainer is a good companion, and you can browse more in the Docker & Containers guides.