Multi-stage builds
A multi-stage build uses several FROM instructions in the same Dockerfile.
Each FROM starts a new stage; you build in a "fat" stage (with compilers, SDKs,
build dependencies) and copy only the final artifact into a "lean" stage that
becomes the published image.
The gain: the final image does not carry the toolchain, package caches, source code or secrets used during the build.
The problem it solves
Without multi-stage, everything you install to compile stays in the image:
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci # includes devDependencies
COPY . .
RUN npm run build # generates dist/
CMD ["node", "dist/server.js"]
Result: an image with the entire node_modules (dev included), source code, the
npm cache, git — easily 1 GB+.
The multi-stage form
# syntax=docker/dockerfile:1
# ---------- stage 1: build ----------
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
# ---------- stage 2: runtime ----------
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
COPY --from=build pulls files from the previous stage. The final image is just
node:20-slim + production node_modules + dist/. Nothing from the TypeScript
compiler, no .git, no cache.
COPY --from
You can copy from:
- another stage, by name:
COPY --from=build /app/bin /usr/local/bin - another stage, by index:
COPY --from=0 ...(the firstFROM) - an external image, without declaring it as a stage:
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY --from=nginx:1.27 /etc/nginx/nginx.conf /etc/nginx/nginx.conf
Stopping at a specific stage
docker build --target builds only up to the requested stage. Great for having
one development/test image and one production image in the same Dockerfile:
# syntax=docker/dockerfile:1
FROM golang:1.23 AS base
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
FROM base AS test
RUN go vet ./... && go test ./...
FROM base AS build
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12 AS prod
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
docker build --target test -t app:test . # runs the tests during the build
docker build --target prod -t app:1.4.0 . # minimal final image
If you do not pass --target, the build goes to the last stage in the file.
Stages are built on demand
BuildKit (the default build engine since Docker 23) builds a dependency graph and
only builds the stages the target needs. In the example above,
--target prod does not run the test stage. Independent stages are built in
parallel.
A shared "base" stage
Reuse dependency installation across stages so you do not repeat it:
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM base AS lint
RUN pip install --no-cache-dir ruff && ruff check .
FROM base AS app
COPY . .
USER 1000
CMD ["python", "-m", "myapp"]
The "build → distroless / scratch" pattern
For static binaries (Go, Rust, C), the final stage can be scratch (empty) or
distroless (only libc + certificates, no shell):
# syntax=docker/dockerfile:1
FROM rust:1.81 AS build
WORKDIR /src
COPY . .
RUN cargo build --release
FROM gcr.io/distroless/cc-debian12
COPY --from=build /src/target/release/myapp /usr/local/bin/myapp
USER nonroot
ENTRYPOINT ["myapp"]
scratch gets you images of a few MB. The cost: there is no shell for
docker exec ... sh and no ping/curl for debugging — debug with
nicolaka/netshoot sharing the namespace (--network container:...,
--pid container:...).
Copying the same artifact for several architectures
Combined with --platform and buildx, each platform runs its own build stage.
See BuildKit and buildx.
Multi-stage-specific best practices
- Give names to stages (
AS build); numeric indexes break when you reorder. - Put
COPY package.json/go.modbefore copying the code to take advantage of layer cache in dependency installation. - Do not
--froma stage you do not need — BuildKit will not build it, but the reader gets confused. - If a secret is only needed at build time (private repository token), use
RUN --mount=type=secretin the build stage — it will not end up in the final image anyway, but this way it also does not stay in the cache.