Docker Multi-Stage Builds
Last reviewed on 2026-09-25
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.
# 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.
Table of Contents
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:
# 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"]
depsinstalls every dependency, andbuildinherits it withFROM depsto run the build.prod-depsinstalls runtime dependencies only. It does not depend onbuild, so BuildKit runs the two in parallel.prodstarts from the slim image and copies in two directories. The source, dev dependencies and npm cache stay behind.- Because
package.jsonand the lockfile are copied before the source, editing code does not re-runnpm 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 unpinnedlatestchanges silently between builds. - A stage name takes precedence. If you have
FROM … AS nginx, then--from=nginxrefers 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 firstFROM, thenFROM ${TOOL_IMAGE} AS tool, thenCOPY --from=tool ….FROMexpands 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:
# 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:
# 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 fortype=local,dest=<path>. After the build,build/out/appexists on the host.- With a local or tar output, no image is created or tagged.
--outputrequires 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
scratchor 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.buildand 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,.gitand 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.