A typical build needs compilers, dev dependencies and build tools that the running application never uses. Ship them, and your image is hundreds of megabytes larger, slower to pull, and has more software for attackers to exploit. Multi-stage builds fix this: you build in one stage and copy only the output into a clean, minimal final stage — all in one Dockerfile.

The idea

FROM <build image> AS build
# install tools, compile, test…

FROM <small runtime image>
COPY --from=build /path/to/output /app/
CMD [...]

Each FROM starts a new stage. Only the last stage becomes the image (unless you pick another with --target). Earlier stages are discarded, along with everything they installed.

Example: Go (the dramatic one)

FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

The Go build image is around 800 MB; the final image is a few megabytes plus your binary. Distroless images contain no shell or package manager — a much smaller attack surface.

Example: Node.js with a build step

FROM node:22-slim 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-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

TypeScript, bundlers and test tools stay in the build stage. The runtime gets production dependencies and compiled output only.

Frameworks with standalone output (Next.js output: 'standalone') make the runtime stage even smaller — copy .next/standalone, .next/static and public.

Example: static front end served by Nginx

FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:stable-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

A React, Vue or Astro site ends up as a ~50 MB Nginx image with no Node.js at all.

Example: Python with compiled dependencies

FROM python:3.12-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
COPY --from=build /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
COPY . .
USER nobody
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]

Compilers build the wheels in the first stage; the final stage installs pre-built wheels without needing gcc.

Useful techniques

Build a specific stage

docker build --target build -t myapp:build .     # e.g. to run tests in CI

Copy from any image

COPY --from also accepts an image name:

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

Test stage that can fail the build

FROM build AS test
RUN npm test

Build it in CI with --target test. With BuildKit (the default builder), stages the final target doesn’t depend on are skipped, so the test stage runs only when you target it.

Cache dependency downloads

BuildKit cache mounts keep package caches between builds without putting them in the image:

RUN --mount=type=cache,target=/root/.npm npm ci
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
RUN --mount=type=cache,target=/go/pkg/mod go mod download

Compare the results

docker images myapp
docker history myapp:latest

Typical savings: Go 800 MB → 10–20 MB; Node with build step 1.2 GB → 150–250 MB; static front end 1 GB → 50 MB.

Common mistakes

  • Copying the whole build directory (COPY --from=build /app /app) — brings node_modules with dev dependencies and source back in. Copy only what you need.
  • Different base OS between stages — a binary compiled against glibc (Debian) may fail on musl (Alpine). Keep the same libc family, or build static binaries.
  • Expecting ENV from earlier stages — environment variables and ARGs don’t carry over between stages; redeclare them.

Smaller images pull faster on every deploy — noticeable when your servers are far from the registry. Learn the basics in the Dockerfile tutorial, and see reducing Docker image size for more tricks.

  • Docker
  • Dockerfile
  • Performance