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.
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:
# ships Debian 12's Python 3.11
FROM gcr.io/distroless/python3-debian12
COPY --from=build /app /appThe 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:
RUN --mount=type=secret,id=index_password,env=UV_INDEX_PRIVATE_PASSWORD \
uv sync --lockeduv 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:
# 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
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:
FROM build AS test
RUN uv sync --locked
RUN pytestA 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
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:appavoids aRUN chown -Rthat duplicates the whole tree in a new layer..dockerignorematters more here than people expect: everything not ignored is sent to the daemon as build context, so.gitandnode_modulesslow down every build.USER appin the final stage. Multi-stage makes this easy because the build ran as root and only the artefact carried over.
Related
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
ARGa safe way to pass a token?” - no. Build args show up indocker history. Use a BuildKit secret mount, which is available to oneRUNand never written to a layer.