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.ukso 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¶
- Containers explanation, the background on our base image collection and why we adopted Docker Hardened Images.
- DevSecOps continuous delivery standard, the compliance requirements this shape satisfies.
uis/devops/platform/base-images/python, the authoritative list of published tags for the Python base image.