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-slim AS build
# uv is not in the Python image
COPY --from=ghcr.io/astral-sh/uv:0.9.0 /uv /bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --no-install-project
COPY src/ src/
RUN uv sync --locked --no-dev --no-editable

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

The full pattern, with cache mounts and the reasons for each flag (found by building it), is in Docker image layers and build caching.

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
# ships Debian 12's Python 3.11
FROM gcr.io/distroless/python3-debian12
COPY --from=build /app /app

The distroless Python images carry the distribution’s Python (3.11 on Debian 12, 3.13 on Debian 13), so a virtualenv built on python:3.14 does not run in them: the interpreter link dangles and compiled wheels are for the wrong ABI. Build the venv with the same Python the final stage provides.

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=index_password,env=UV_INDEX_PRIVATE_PASSWORD \
    uv sync --locked

uv ignores PIP_* variables; private index credentials go in UV_INDEX_<NAME>_USERNAME / _PASSWORD, and the secret mount’s env= option (Dockerfile syntax 1.10+) exposes it to that one command.

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
# manifests change rarely; source changes every commit
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-install-project
COPY src/ src/

A comment after an instruction is not a comment in a Dockerfile: mid-line, # changes rarely becomes extra arguments and the COPY fails. Comments go on their own line.

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.

--locked also matters: it installs exactly the lockfile and fails if the lock is stale. --frozen uses the lockfile without checking it, so a stale lock installs silently; keep it for cases where the check is impossible.

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 --locked
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:0.9.0 /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.