Backend / Docker / 08_multi_stage_from.md

Multi-stage builds

Updated 5 interview angles 4 min read source
On this page7
  1. Size, and the thing that matters more
  2. Secrets never reach the final image
  3. Layer caching decides your build time
  4. Copying from places other than a stage
  5. Practical extras
  6. Related
  7. Interview angle

Multi-stage builds

One Dockerfile, several FROM lines. Each FROM starts a new stage with its own filesystem; the final stage becomes the image, and everything else is discarded. You copy just the artefact forward.

dockerfile
FROM python:3.14 AS build
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

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

The build stage has compilers and headers; the runtime stage has neither. That is the whole idea, and it buys two separate things.

Size, and the thing that matters more

Dropping a build toolchain typically takes an image from ~1 GB to ~150 MB. Faster pulls, cheaper registry, quicker scale-up.

The security argument is the stronger one. An attacker with execution inside a container that has gcc, curl, git and a package manager has a toolkit. One with a Python runtime and your code has much less to work with. This is the reasoning behind distroless and scratch final stages:

dockerfile
FROM gcr.io/distroless/python3-debian12
COPY --from=build /app /app

No shell at all, which also means no docker exec ... sh — a real operational trade-off, not a free win.

Secrets never reach the final image

A credential used in a build stage is not in the final image, because only the copied paths carry over. That is better than deleting it in a later RUN, where it stays recoverable in the earlier layer.

Better still, do not let it into any layer:

dockerfile
RUN --mount=type=secret,id=pip_token \
    PIP_INDEX_URL=$(cat /run/secrets/pip_token) uv sync

BuildKit mounts the secret for that command only; it is never written to a layer. ARG is not a secret mechanism — build args are visible in docker history.

Layer caching decides your build time

Order instructions from least to most frequently changed:

dockerfile
COPY pyproject.toml uv.lock ./   # changes rarely
RUN uv sync --frozen            # cached until they do
COPY src/ src/                  # changes every commit

Copying source before installing dependencies invalidates the install layer on every code change, which turns a 5-second rebuild into a 3-minute one. This is the single most common Dockerfile mistake.

--frozen (or --no-deps with a lockfile) also matters: a resolver that can pick new versions makes the cached layer non-reproducible.

Targeting a stage

bash
docker build --target build -t myapp:dev .

--target stops at a named stage, so a test image and a production image come from one Dockerfile instead of two that drift:

dockerfile
FROM build AS test
RUN uv sync --frozen        # including dev deps
RUN pytest

A stage that nothing copies from and nothing targets is never built at all — stages are lazy, so an unused test stage costs nothing in production builds.

Copying from places other than a stage

dockerfile
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv

--from also accepts an image, which is the tidiest way to pull a single binary in without a package manager or a curl | sh.

Practical extras

  • COPY --chown=app:app avoids a RUN chown -R that duplicates the whole tree in a new layer.
  • .dockerignore matters more here than people expect: everything not ignored is sent to the daemon as build context, so .git and node_modules slow down every build.
  • USER app in the final stage. Multi-stage makes this easy because the build ran as root and only the artefact carried over.

Interview angle 5

  • “What is a multi-stage build for?” - separating build from runtime. Compilers, headers and dev dependencies live in the build stage; only the artefact is copied into a slim final image. That cuts size dramatically and removes tooling an attacker could use.
  • “How does it help with secrets?” - a credential used during build never appears in the final image’s layers, since only the copied artefact carries over. Better still, use build secrets so it never lands in any layer.
  • “Can you target an intermediate stage?” - yes, --target, which is how you build a test image from the same Dockerfile without duplicating it.
  • “Why is my rebuild always slow?” - almost always instruction order. Copying source before installing dependencies invalidates the dependency layer on every commit; copy the lockfile, install, then copy source.
  • “Is ARG a safe way to pass a token?” - no. Build args show up in docker history. Use a BuildKit secret mount, which is available to one RUN and never written to a layer.