Docker Multi-Stage Builds

Last reviewed on 2026-09-25

12 min read Intermediate Updated: Sep 25, 2026

Quick answer

A multi-stage build is a Dockerfile with several FROM lines. Build in one stage, then COPY --from only the result into a small runtime stage. docker build produces the last stage; everything else is left behind.

Dockerfile
# 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 go build -o /out/app .

FROM gcr.io/distroless/static-debian12 AS prod
COPY --from=build /out/app /app
USER nonroot
ENTRYPOINT ["/app"]
docker build -t myapp .                  # builds the last stage (prod)
docker build --target build -t myapp:build .  # stops at the build stage

The golang image is several hundred MB; the resulting image is the distroless base plus one static binary. For the exact syntax rules, see the multi-stage build syntax reference.

How multi-stage builds work

Each FROM starts a new stage with a fresh filesystem. Nothing carries over between stages automatically: a later stage sees files from an earlier one only if it copies them with COPY --from=<stage>, or if it is based on it with FROM <stage>. The image you get is the filesystem of the target stage — the last one by default — so whatever the earlier stages installed (compilers, dev dependencies, source code, package caches) never reaches it.

Name stages with AS <name> so that COPY --from and --target can refer to them. You can also refer to a stage by its zero-based index (COPY --from=0), but indexes break as soon as someone inserts a stage.

Example: Node.js app

A single-stage Node.js Dockerfile ships the full node image, all dev dependencies and the source tree. Splitting it into stages keeps only production dependencies and the compiled output:

Dockerfile (Node.js)
# syntax=docker/dockerfile:1
FROM node:22 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build

FROM node:22 AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:22-slim AS prod
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
  • deps installs every dependency, and build inherits it with FROM deps to run the build.
  • prod-deps installs runtime dependencies only. It does not depend on build, so BuildKit runs the two in parallel.
  • prod starts from the slim image and copies in two directories. The source, dev dependencies and npm cache stay behind.
  • Because package.json and the lockfile are copied before the source, editing code does not re-run npm ci. The layer caching reference explains why.

COPY --from a stage, with --chown

COPY --from=<stage> reads from another stage's filesystem instead of the build context. Paths are absolute in the source stage, and .dockerignore does not apply to them.

COPY --from=build /app/dist ./dist
COPY --from=build /out/app /usr/local/bin/app
COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

To copy from a previous stage with a specific user as owner, add --chown (and --chmod if needed). This sets ownership as the files are written. A separate RUN chown -R would add a second layer holding a full copy of the same files.

FROM alpine:3.20 AS runtime
RUN addgroup -S app && adduser -S -G app app
COPY --from=build --chown=app:app /app/dist /srv/app
COPY --from=build --chown=app:app --chmod=0755 /out/entrypoint.sh /usr/local/bin/
USER app

User and group names in --chown are looked up in /etc/passwd and /etc/group of the stage being copied into, not the source stage. scratch has no such files, and the build fails if you use a name there. Use numeric IDs instead:

FROM scratch
COPY --from=build --chown=65532:65532 /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]

A user without a group (--chown=10001) sets the GID to the same number.

COPY --from an external image

If --from is not a stage name, Docker treats it as an image reference. It pulls the image and copies from its filesystem, so you can take a single binary or config file from an image without adding a stage:

FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:0.4.20 /uv /uvx /usr/local/bin/
COPY --from=nginx:1.27-alpine /etc/nginx/mime.types /etc/app/mime.types
  • This works with both BuildKit and the legacy builder (DOCKER_BUILDKIT=0). Copying from an external image has been supported since multi-stage builds were added in Docker 17.05.
  • Pin a tag, or better a digest (image@sha256:…). An unpinned latest changes silently between builds.
  • A stage name takes precedence. If you have FROM … AS nginx, then --from=nginx refers to that stage, not the image.
  • To make the image configurable, don't put a variable in --from. Declare it as a stage instead: ARG TOOL_IMAGE=… before the first FROM, then FROM ${TOOL_IMAGE} AS tool, then COPY --from=tool …. FROM expands build arguments on every builder.

--target: dev, test and prod from one Dockerfile

docker build --target <stage> stops at the named stage and outputs it as the image. That lets one Dockerfile produce several images:

Dockerfile (dev / test / prod targets)
# syntax=docker/dockerfile:1
FROM node:22 AS base
WORKDIR /app
COPY package.json package-lock.json ./

FROM base AS dev
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]

FROM dev AS test
RUN npm run lint && npm test

FROM dev AS build
RUN npm run build

FROM base AS prod-deps
RUN npm ci --omit=dev

FROM node:22-slim AS prod
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
docker build --target dev  -t myapp:dev .    # local development image
docker build --target test .                  # CI: fails the build if tests fail
docker build --target prod -t myapp:1.4.0 .   # release image (also the default)

With BuildKit, only the stages the target depends on are built. --target prod never runs test, and --target test never runs prod-deps. Stages shared by two targets, like dev, are cache hits the second time. The legacy builder behaves differently: it builds every stage that comes before the target in the file, needed or not.

Staging: build prod once and promote the same image (same digest) from staging to production. Put environment-specific settings in runtime configuration, not in separate build targets. A separate "staging" target means production runs an image that was never tested.

To build several targets in one command, use Bake:

# docker-bake.hcl
group "default" {
  targets = ["dev", "prod"]
}
target "dev" {
  target = "dev"
  tags   = ["myapp:dev"]
}
target "prod" {
  target = "prod"
  tags   = ["myapp:latest"]
}
docker buildx bake

Exporting build artifacts with -o

Sometimes you don't want an image. You want the compiled binary, a static site, or test reports on the host. BuildKit can write a stage's filesystem straight to a directory with --output (-o). Put the artifacts in a FROM scratch stage, because the entire filesystem of the target stage is exported:

Dockerfile (export stage)
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app .

FROM scratch AS export
COPY --from=build /out/app /

FROM gcr.io/distroless/static-debian12 AS prod
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
# Write the export stage's files to ./build/out on the host
docker build -f Dockerfile --target export -o build/out .

# Same thing, explicit exporter
docker build --target export --output type=local,dest=build/out .

# As a tarball
docker build --target export --output type=tar,dest=out.tar .
  • -o <path> is shorthand for type=local,dest=<path>. After the build, build/out/app exists on the host.
  • With a local or tar output, no image is created or tagged.
  • --output requires BuildKit, which is the default builder since Docker Engine 23.0. The legacy builder does not support it.
  • If you export a non-scratch stage, you get the whole OS tree of that stage (/bin, /etc, …) in the directory.

Benefits of multi-stage builds

  • Smaller images. The runtime image holds only what the application needs. Compiled languages often go from hundreds of MB to tens of MB, or less with scratch or distroless bases.
  • Smaller attack surface. No compilers, shells or package managers in production means fewer CVEs to patch and fewer tools for an attacker.
  • One Dockerfile. No separate Dockerfile.build and wrapper script to copy artifacts between two images.
  • Better caching. Dependencies, tests and builds live in separate stages that are cached independently.
  • Parallel and selective builds. BuildKit runs independent stages concurrently and skips stages the target does not need.
  • Several outputs from one file. Dev, test and prod images, plus exported artifacts, all come from the same stages.

Best practices

  • Name every stage (AS build, AS prod). Don't use numeric indexes.
  • Pin base images by version, or by digest for release builds. Avoid latest.
  • Copy dependency manifests before source in each stage so that dependency installation stays cached. The caching techniques tutorial covers this, plus cache mounts and CI cache.
  • Copy specific paths from build stages (/app/dist, not /app).
  • Run the final stage as a non-root USER and set ownership with COPY --chown.
  • Use a .dockerignore so that node_modules, .git and local build output don't enter the build context.
  • Put the production stage last so that a plain docker build . produces it.

FAQ

What is a Docker multi-stage build?

A multi-stage build is a Dockerfile with more than one FROM instruction. Each FROM starts a new stage with its own filesystem. Later stages copy only the files they need from earlier stages with COPY --from, so compilers, package managers and source code stay out of the final image. docker build produces the last stage unless you pick another one with --target.

How do I copy files from a previous build stage, and set the owner?

Use COPY --from=<stage> <src> <dest>, where <stage> is the name given with FROM ... AS <stage> or the stage's zero-based index. Add --chown=<user>:<group> (and optionally --chmod) to set ownership as the files are written. User and group names are resolved against /etc/passwd and /etc/group of the stage you are copying into, so in scratch or distroless images use numeric IDs such as --chown=65532:65532.

Can COPY --from copy from an external image, and does that need BuildKit?

Yes. If the value of --from is not a stage name, Docker treats it as an image reference, pulls it and copies from its filesystem, for example COPY --from=nginx:1.27-alpine /etc/nginx/nginx.conf /etc/nginx/. This works with both BuildKit and the legacy builder. If a stage has the same name as the image, the stage wins.

How do I build dev, test and production images from one Dockerfile?

Give each variant its own named stage and select it with --target, for example docker build --target dev -t myapp:dev . and docker build --target prod -t myapp:prod . With BuildKit only the stages the target depends on are built. For staging, promote the same production image rather than building a separate one; put environment differences in runtime configuration.

How do I export build artifacts from a stage to the host instead of creating an image?

Add a stage based on FROM scratch that contains only the artifacts, then build it with an output: docker build --target export -o build/out . (short for --output type=local,dest=build/out). The whole filesystem of the target stage is written to the directory, which is why the export stage starts from scratch. This requires BuildKit.

What are the benefits of multi-stage builds?

Smaller final images because build tools and intermediate files are left behind; a smaller attack surface and fewer CVEs to patch; one Dockerfile instead of separate build and runtime Dockerfiles or wrapper scripts; separate cacheable stages for dependencies, tests and builds; and with BuildKit, independent stages run in parallel and unused stages are skipped.

Next: faster builds

Layer ordering, RUN --mount=type=cache, and sharing the build cache in CI.

Docker Build Caching Techniques