Server

Multi-Stage Docker Builds Explained

By Prabath Thalangama· October 24, 2026· 3 min read
#docker#dockerfile#optimization

Introduction

A naive Dockerfile ships the compiler, package manager, headers, and build caches in the final image. Multi-stage builds let you use all that tooling in an intermediate stage and copy only the finished artifact into a minimal runtime image.

The pattern

Each FROM starts a new stage. Later stages COPY --from=<stage> files out of earlier ones. Only the last stage becomes the image; everything else is discarded.

Go

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 ./cmd/app

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

Result: ~10–15 MB instead of ~900 MB, with no shell, no package manager, nothing to exploit.

Node

FROM node:20 AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:20 AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./
USER node
CMD ["node", "dist/main.js"]

Python

FROM python:3.12 AS build
WORKDIR /app
RUN python -m venv /venv
ENV PATH=/venv/bin:$PATH
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM python:3.12-slim
COPY --from=build /venv /venv
ENV PATH=/venv/bin:$PATH
WORKDIR /app
COPY . .
USER 1000
CMD ["python", "-m", "app"]

Caching rules that matter

  • Copy dependency manifests before source. COPY go.mod go.sum / COPY package*.json then install, then COPY . .. A code change doesn't invalidate the dependency layer.
  • Order instructions least- to most-frequently-changing.
  • --mount=type=cache (BuildKit) keeps package caches between builds without baking them in:
    RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
    

Targeting a stage

docker build --target build -t myapp:debug .   # stop at the build stage for debugging
docker build -t myapp:latest .                 # full build

CI can build the test stage to run tests, then the runtime stage for the artifact, reusing all cached layers.

Verification and troubleshooting

docker image ls myapp
dive myapp:latest              # layer-by-layer size + wasted space
docker history myapp:latest
docker run --rm myapp:latest --version
  • Final image still huge — you COPY . . into the last stage instead of copying only the artifact from --from=build, or the base is python:3.12 not -slim.
  • COPY --from=build /out/app — no such file — the build stage put it elsewhere, or the build actually failed but a later RUN masked the exit code. Build with --target build and inspect.
  • distroless image: "no such file or directory" on a binary that exists — it's dynamically linked and the runtime lacks the libs. Build static (CGO_ENABLED=0 for Go) or use distroless/base (has glibc).
  • Cache never hits in CI — no persistent layer cache. Use docker build --cache-from with a registry image, or BuildKit's --cache-to=type=registry.
  • Secrets leaked into a layer — even in an early stage, RUN echo $TOKEN is recoverable if that stage is pushed. Use --mount=type=secret, never ARG for secrets.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.