Stop Shipping Build Tools in Production Docker Images
Use multi-stage Docker builds to install dependencies in a builder stage and ship only runtime artifacts, reducing image size and attack surface.
29 Aug 2026, 14:05 UTC

Your production image is carrying a compiler it will never use
You build a Python application that depends on psycopg2 or another package with C extensions. To install it, your Dockerfile installs gcc, make, and Python header files. The installation succeeds, but those build tools stay inside the image. Your orchestrator pulls, stores, and scans a runtime image that contains a compiler, package cache, and header files your running container never executes. The useful takeaway: you can build those dependencies in one stage and copy only the installed packages into a second, minimal stage.
What changes in a multi-stage Dockerfile
A multi-stage Dockerfile contains more than one FROM instruction. Each FROM starts a new stage, and a later stage can copy files from an earlier one using COPY --from=builder. Only the final stage becomes the image you tag and push; everything in earlier stages is discarded at build time. Multi-stage builds have been available in Docker Engine since version 17.05. Run docker version to confirm your engine meets that baseline before relying on the syntax.
Worked example: install Python packages without shipping gcc
This example assumes a local Python project with a requirements.txt that includes packages needing compilation. The first stage, named builder, uses a full Python image to install dependencies into a user site directory. The second stage uses a slim Python runtime and copies only the installed packages.
# syntax=docker/dockerfile:1
FROM python:3.11-slim AS builder
WORKDIR /build
RUN apt-get update && apt-get install --no-install-recommends -y gcc make libffi-dev
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
ENV PATH=/root/.local/bin:$PATH
COPY app.py .
ENTRYPOINT ["python", "app.py"]
Run this from the directory containing the Dockerfile:
docker build -t <registry>/<app-name>:<tag> .
Where to run: your local terminal or a CI runner. Permissions: your user must be able to execute Docker commands, usually through membership in the docker group or with sudo. Risks: if COPY --from=builder names a stage that does not exist or appears after the current stage, the build fails. The base image tags above are examples; confirm they exist in your registry before relying on them in CI.
Verify what the final image contains
Build the image and compare its size to an equivalent single-stage image:
docker images <registry>/<app-name>
Then start a throwaway container and check for build tools:
docker run --rm <registry>/<app-name>:<tag> sh -c "which gcc || true; which make || true; ls /usr/local/lib/python3.11/site-packages | head"
Expected result: gcc and make are not found, while your installed packages appear in site-packages. If gcc appears, the copy step pulled from the wrong stage or the runtime stage reinstalled build tools.
Build cache note: intermediate layers can remain in Docker's build cache after the build. Multi-stage builds reduce the shipped image, but they do not automatically prune that cache.
Trade-offs to accept
- The final image still inherits vulnerabilities from its runtime base image. Multi-stage builds do not replace image scanning.
- Removing build tools makes in-container debugging harder. If you need to inspect a running container, add a dedicated debug stage or use sidecar tooling rather than shipping compilers in production.
- COPY --from references a stage by name or index; a missing or misordered stage breaks the build.
Ship the runtime, not the toolchain
Start with your largest production image. Split the Dockerfile into a builder stage and a runtime stage, copy only the artifacts the application needs, and compare image sizes. Because building a new image does not overwrite an existing tag, you can re-deploy the previous image if the new build behaves unexpectedly.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.