Skip to content

Build a multi-stage Dockerfile with our Python base images

In this tutorial we'll walk through building a small Python web service and packaging it in a multi-stage Dockerfile that consumes our Unified DevOps Platform base images. We'll start with the smallest possible distroless image, then extend it with a native OS dependency to demonstrate our runtime-libs pattern for getting additional OS dependencies into distroless images. By the end you'll have a running container built from a distroless production image, and a working mental model of why it is so important that our Dockerfiles are written as modern multi-stage builds.

What we'll build

We'll build the tutorial in two phases.

First, we'll write a minimal Flask service with a single / endpoint that returns a JSON greeting, and package it as a distroless multi-stage image. That gives us the smallest possible working artefact — a curl-able container running from an image with no shell and no package manager.

Then we'll extend the same app so its endpoint uses python-magic, a wheel that dlopens the system libmagic shared library at runtime. To make that work inside a distroless image we'll introduce a small runtime-libs build stage that bundles the native OS package we need and copies it into the runtime image alongside our Python venv.

The end result is a small, hardened image containing only the Python runtime, our dependencies, the libmagic shared library, and our code.

Prerequisites

  • Docker Desktop installed and running. See prepare your system for the division's install instructions.
  • A basic understanding of what a container is and why we prefer distroless base images. The containers explanation covers the background.
  • Signed in to registry.gitlab.developers.cam.ac.uk so Docker can pull our base images. See the base-images group for how to authenticate if you have not done so before.

The base multi-stage image

We'll build the simplest possible distroless image first. This will include Flask, one endpoint, and no native dependencies.

Create the application

Create a fresh directory to work in, then add a tiny Flask app in app.py:

from flask import Flask

app = Flask(__name__)


@app.route("/")
def hello():
    return {"message": "Hello from a hardened container"}

Alongside it, add a requirements.txt pinning the one dependency we need:

flask==3.0.3

That's the whole application. Two files, no framework scaffolding to distract from the container work.

Write the build stages

The build side of our Dockerfile is split into two named stages. A shared base stage carries the configuration every build stage should inherit, and a deps stage installs our Python dependencies into a virtual environment. Create a Dockerfile alongside app.py:

FROM registry.gitlab.developers.cam.ac.uk/uis/devops/platform/base-images/python:3.14-debian13-dev AS base

# Some performance and disk-usage optimisations for Python within a docker container.
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1

# Do everything relative to /usr/src/app which is where we install our application.
WORKDIR /usr/src/app

###############################################################################
# Prod venv at /opt/venv. We build it here so we can COPY it into the distroless runtime,
# which has no pip.
FROM base AS deps

COPY requirements.txt ./

RUN python -m venv --copies /opt/venv \
    && /opt/venv/bin/pip install --no-cache-dir -r requirements.txt

The base stage sets PYTHONUNBUFFERED and PYTHONDONTWRITEBYTECODE, which stop Python from buffering stdout and from writing .pyc files, and picks /usr/src/app as the working directory. The deps stage builds on base and installs our dependencies into a virtual environment at /opt/venv. We pass --copies to venv so the interpreter is copied into the venv rather than symlinked, which lets the venv keep working once we copy it into the runtime stage. --no-cache-dir tells pip not to leave its download cache behind, which keeps the stage lean.

Info

We prefer a venv here over pip install --target because a venv is self-contained and avoids the .pth and namespace-package issues that --target can introduce.

Add the runtime stage

Now append the production runtime stage to the same Dockerfile. This stage uses the base variant of the Python image, so it is distroless. It has no shell and no pip available, so it cannot install anything itself.

###############################################################################
# Distroless production image. Contains only the Python runtime, our venv, and our code.
FROM registry.gitlab.developers.cam.ac.uk/uis/devops/platform/base-images/python:3.14-debian13 AS production

ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /usr/src/app

COPY --from=deps /opt/venv /opt/venv
COPY app.py .

ENV PATH=/opt/venv/bin:$PATH
EXPOSE 8000

# Note: this example uses flask's development server which is not fit for production use.
CMD ["python", "-m", "flask", "--app", "app", "run", "--host=0.0.0.0", "--port=8000"]

Two things are worth pointing out. COPY --from=deps /opt/venv /opt/venv pulls the whole virtual environment we built in the previous stage into this one, so we get the installed libraries without inheriting the tools that installed them. Prepending /opt/venv/bin to PATH makes the venv's python the default interpreter, and because it is a real venv it automatically picks up its own site-packages.

We also re-set PYTHONUNBUFFERED and PYTHONDONTWRITEBYTECODE here because this stage does not inherit from base, and both flags matter at runtime too.

Notice what isn't in this stage. There is no RUN, no pip, no shell command of any kind. The distroless variant does not have a shell for those to run in. Everything the container needs to do was already done in the build stages, and the runtime image contains only the result.

Build the image

From the same directory, build the image:

docker build -t hello-service .

Docker pulls both base image variants, runs the build stage, copies the installed dependencies into the production runtime stage, and produces a single tagged image called hello-service.

Run the container

Start the container and expose port 8000 on your host:

docker run --rm -p 8000:8000 hello-service

From a second terminal, hit the endpoint with curl:

$ curl http://localhost:8000
{"message":"Hello from a hardened container"}

You now have a working distroless container built from our Python base images. The rest of the tutorial extends this image with a native OS dependency to demonstrate how we do this.

Adding a native dependency

Many Python wheels (and Node native modules etc.) dlopen a system shared library at runtime. On a distroless image there is no apt-get and no shell, so those libraries can't be installed in the runtime stage. Instead we assemble them in a small dedicated build stage we call runtime-libs, then copy the resulting file tree across into the production stage.

The concrete example we'll use is python-magic, a wheel that wraps the libmagic file-type detection library. pip install python-magic works fine on its own, but the wheel only runs if the libmagic1 Debian package is present at runtime.

Update the app to use libmagic

Add python-magic to requirements.txt:

flask==3.0.3
python-magic==0.4.27

And change app.py so the endpoint uses it:

import magic
from flask import Flask

app = Flask(__name__)


@app.route("/")
def detect():
    return {"detected": magic.from_buffer(b"hello, world")}

python-magic is a pure-Python ctypes wrapper, so pip installs the wheel without needing a compiler or headers. But because it dlopens libmagic.so.1 at runtime, an image without the native libmagic1 package fails as soon as the wheel is imported. If you were to rebuild and re-run now, docker run would exit with something like ImportError: failed to find libmagic. That is what motivates the next step.

Add the runtime-libs stage

The runtime-libs stage is a build-side stage that uses apt-get to download the native packages we need, extracts each .deb into a relocatable /rootfs tree, and reconstructs the dpkg metadata that Trivy needs to see them. Because it uses apt-get and a shell, it builds FROM base (the dev base image variant), not from the distroless production image.

Add the following stage to the Dockerfile, directly after the deps stage:

FROM base AS runtime-libs

ARG RUNTIME_PACKAGES="libmagic1"

RUN apt-get update && apt-get -o Dir::State::Status=/dev/null \
        install --download-only -y --no-install-recommends ${RUNTIME_PACKAGES} \
    && mkdir -p /rootfs \
    && for deb in /var/cache/apt/archives/*.deb; do dpkg-deb -x "$deb" /rootfs; done \
    && mkdir -p /rootfs/var/lib/dpkg/status.d \
    && for deb in /var/cache/apt/archives/*.deb; do \
        pkg="$(dpkg-deb -f "$deb" Package)"; \
        dpkg-deb -e "$deb" /tmp/ctrl; \
        cat /tmp/ctrl/control > "/rootfs/var/lib/dpkg/status.d/$pkg"; \
        rm -rf /tmp/ctrl; \
    done \
    && ldconfig -r /rootfs \
    && rm -rf /var/lib/apt/lists/*

Two parts of this are worth pausing on. apt-get install --download-only fetches the .deb files into /var/cache/apt/archives/ without installing them into the stage's own filesystem. We then dpkg-deb -x each archive into /rootfs, which gives us a self-contained tree of just the files those packages ship. Because it's an independent tree we can copy it into any other stage, including one that isn't derived from Debian at all.

The second for loop reconstructs /var/lib/dpkg/status.d/<package> entries for every .deb we extracted. This is the metadata layout distroless images use and the layout Trivy reads when it scans an image for known vulnerabilities. Without it the extracted libraries are invisible to the scanner, and real CVEs against, say, libmagic1 would fail to be reported for the image. Do not simplify this loop away.

Extending RUNTIME_PACKAGES with additional packages when your project needs them (for example libpq5 for psycopg, or libxml2 for lxml) is a one-line change to the ARG. Simply add each required package separated by a space.

Copy the native libraries into the runtime stage

Add a COPY --from=runtime-libs line to the production stage, immediately before the venv copy:

COPY --from=runtime-libs /rootfs/ /
COPY --from=deps /opt/venv /opt/venv

The trailing slashes matter! They merge /rootfs into / rather than replacing it. The runtime-libs copy goes before the venv copy so the shared libraries land under /usr/lib/* first, which keeps the dynamic loader's search order unsurprising.

Rebuild and verify

Rebuild the image, reusing the same tag so the new build replaces the previous one:

docker build -t hello-service .

Then re-run and hit the endpoint again:

$ curl http://localhost:8000
{"detected":"ASCII text, with no line terminators"}

If you get that response back, libmagic1 resolved correctly from the runtime-libs stage inside a distroless image.

What we did

We built the same container in two passes. First, a base multi-stage build: a shared base stage, a deps stage that installed our Python dependencies into a virtual environment, and a distroless production stage that copied the venv and our code across. Then we extended that image to depend on a native OS shared library by adding a runtime-libs build stage that downloads and unpacks the .debs into a relocatable /rootfs tree, and adding a COPY --from=runtime-libs line to the production stage.

The result has no shell, no package manager, and runs as a non-root user by default.

Multi-stage is not a nice-to-have with our base images. Because the distroless variant is what runs in production, it is the only shape that works. You cannot pip install inside a distroless image, and you cannot apt-get install either, so both Python dependencies and native OS libraries have to be assembled elsewhere and copied in.

See also