Skip to content

How to Use Multi-Stage Builds in Docker

Use Docker multi-stage builds to ship smaller, safer images: build in one stage, copy only the artifact into a clean final stage, and drop compilers and secrets.

SDSysadmin Desk October 24, 2026 8 min read
Diagram-style cover showing a Docker build stage with compilers feeding a small clean runtime stage that contains only the finished binary

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.

Frequently asked questions

What is a multi-stage build in Docker?

It's a Dockerfile that uses more than one FROM instruction. Each FROM starts a new stage, and you copy only the files you need from an earlier stage into the final one. The earlier stages — with compilers, dev dependencies, and build caches — are thrown away, so the image you ship contains just the finished artifact and its runtime.

Do multi-stage builds make images smaller?

Usually a lot smaller. A Go binary that needs a 300 MB+ build toolchain can ship in a final image of a few megabytes, because the compiler never makes it into the last stage. For interpreted languages the win is smaller but still real — you drop build tools, caches, and dev packages.

How do I copy files from one build stage to another?

Use COPY --from=. Name a stage with AS, for example FROM golang:1.23 AS build, then in the final stage write COPY --from=build /src/app /app. You can also copy from an external image with COPY --from=nginx:latest.

Can I build a specific stage instead of the whole Dockerfile?

Yes. Run docker build --target build -t myapp:build . to stop at the named stage. This is handy for running tests in a build stage during CI, or for debugging an intermediate stage without producing the final image.

Do multi-stage builds keep secrets out of the final image?

They help, because anything used only in an early stage is not present in the final image's layers. But never bake secrets into any layer with COPY or ARG — those can be extracted from the build history. Use BuildKit secret mounts (RUN --mount=type=secret) for credentials needed at build time.

Are multi-stage builds slower to build?

Not meaningfully. BuildKit builds independent stages in parallel and caches each one, so rebuilds are often faster. You only pay for stages that actually feed the final target, and unused stages are skipped.

Sources & further reading

Official vendor documentation referenced while writing this guide.

SD

Sysadmin Desk

Infrastructure & Cloud

Hands-on guidance for infrastructure, virtualization, and containers — Hyper-V, VMware, Docker, and the day-to-day operations work that keeps environments running.

MCSA Guru provides independent, educational IT guidance. Microsoft, Windows, Windows Server, Microsoft 365, Exchange, and Microsoft Teams are trademarks of Microsoft Corporation; Docker is a trademark of Docker, Inc. MCSA Guru is not affiliated with or endorsed by Microsoft or Docker. Always test changes in a safe environment before applying them in production.

Related guides

Fixing something right now?

Jump straight into the guide library or search for the exact error or task you are dealing with.