Multi-Stage Docker Builds Explained
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*.jsonthen install, thenCOPY . .. 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 ispython:3.12not-slim. COPY --from=build /out/app— no such file — the build stage put it elsewhere, or the build actually failed but a laterRUNmasked the exit code. Build with--target buildand 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=0for Go) or usedistroless/base(has glibc). - Cache never hits in CI — no persistent layer cache. Use
docker build --cache-fromwith a registry image, or BuildKit's--cache-to=type=registry. - Secrets leaked into a layer — even in an early stage,
RUN echo $TOKENis recoverable if that stage is pushed. Use--mount=type=secret, neverARGfor secrets.
Related tools and reading¶
- On-site: Web server config generator, Base64 decoder (for inspecting k8s secrets).
- Related posts: Dockerfile best practices, Shrink a Docker image.
Stuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.More in Docker & Kubernetes
StatefulSets Explained
StatefulSets give pods sticky names, sticky storage, and ordered operations — what databases and clustered apps need. Here's how each guarantee works.
October 24, 2026Persistent Volumes and Claims, Explained
PVC is a request, PV is the fulfilled storage, StorageClass is the factory. Most 'Pending PVC' problems are an access-mode or provisioner mismatch.
October 24, 2026Kubernetes Jobs and CronJobs
A Job runs pods until they succeed; a CronJob creates Jobs on a schedule. Here's parallelism, retry limits, the missed-schedule trap, and history cleanup.
October 24, 2026