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 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:
FROM gcr.io/distroless/python3-debian12
COPY --from=build /app /appNo 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=pip_token \
PIP_INDEX_URL=$(cat /run/secrets/pip_token) uv syncBuildKit 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:
COPY pyproject.toml uv.lock ./ # changes rarely
RUN uv sync --frozen # cached until they do
COPY src/ src/ # changes every commitCopying 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
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 --frozen # including dev deps
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: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: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.