Recipe: a Python web app
A slim, non-root, hash-locked image for a WSGI app.
cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-hard/r-python.tar.gz && tar -xzf r-python.tar.gz, which creates ~/lab/r-python/. SHA-256: 518d2c96ccd81912b3555d94d700b5caa4aecfe7842ab4260630e2a112587611Here you'll package a small Flask service with a hashed lock file and a numeric non-root user. A virtual environment is built in one stage and copied into a slim final stage, and the health check doesn't need curl. The case behind it is an image scan that flags gunicorn 22.0.0. That's CVE-2024-6827, an HTTP request-smuggling bug fixed in 23.0.0. The bump itself is one line. The follow-up work is harder. You need to find which packages float unpinned with every build and what else the bump changed (a different Werkzeug, for example). You also need to know whether the build would notice a mirror serving a modified wheel.
Everything runs on the main lab VM, secopslog-docker, as ubuntu in ~/lab/r-python. The lesson files download has the app, the requirements files, the Dockerfile, .dockerignore and compose.yaml. "BuildKit builds: stages, cache and mounts" explains the BuildKit mechanics used here (stages, COPY --from, cache mounts); here you apply them to Python.
The app and its lock
import osimport socketfrom flask import Flask, jsonifyapp = Flask(__name__)@app.get("/health")def health():return jsonify(status="ok")@app.get("/")def index():# print() writes to stdout; the request shows up in `docker logs` only if stdout is unbufferedprint(f"served / from {socket.gethostname()}")return jsonify(service="orders", worker_pid=os.getpid())
/health is for the health check. / answers with the worker's process ID and prints a line, which the buffering section uses later. The dependencies come in two files. requirements.in names what the code imports, pinned to exact releases. requirements.txt is generated from it and pins everything those packages pull in, each with the SHA-256 hashes of the files PyPI publishes for that release:
Two direct dependencies became eight pinned packages with 165 hashes between them. Most packages have two hashes (a wheel and a source archive). markupsafe has dozens because it ships a compiled wheel per Python version, OS and CPU. The lock lists all of them, so the same file installs on arm64 and amd64. When pip sees hashes in a requirements file, it switches to hash-checking mode. Every file it downloads must match a listed hash, and every requirement must be pinned and hashed. A lock is only useful if you can regenerate and review it, so the lab rebuilds it with uv in a throwaway container and compares:
No diff, so the committed lock is exactly what requirements.in resolves to. --exclude-newer makes the resolution reproducible. uv ignores anything published after that date, so rerunning the command next month gives the same file until you move the date on purpose. To bump gunicorn, change requirements.in and the date, regenerate, and review the diff. It shows every transitive change the bump brings. pip-compile --generate-hashes from pip-tools makes the same kind of file if you prefer it. Now the protection itself, with a requirement whose hash doesn't match what PyPI serves:
pip downloaded gunicorn 26.2.0, computed its hash and refused to install it. A tampered mirror, a compromised cache or a re-uploaded file fails the same way, before any of its code runs. The hashes say nothing about whether the original release is safe. That's what scanning and update tooling are for ("Pinning, SBOMs, provenance and scanning" (Advanced container security)).
The Dockerfile
# syntax=docker/dockerfile:1FROM python:3.15-slim AS buildENV PIP_DISABLE_PIP_VERSION_CHECK=1RUN python -m venv /opt/venvCOPY requirements.txt /tmp/requirements.txtRUN --mount=type=cache,target=/root/.cache/pip \/opt/venv/bin/pip install --require-hashes --only-binary=:all: -r /tmp/requirements.txtFROM python:3.15-slimENV PATH=/opt/venv/bin:$PATH \PYTHONUNBUFFERED=1 \PYTHONDONTWRITEBYTECODE=1COPY --from=build /opt/venv /opt/venvWORKDIR /appCOPY app.py .USER 10001:10001EXPOSE 8000HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "--no-control-socket", "app:app"]
The build stage creates a virtual environment in /opt/venv and installs the lock into it. --require-hashes makes hash checking mandatory, even if someone later adds an unhashed line. --only-binary=:all: forbids source distributions, so pip installs wheels or fails. It never falls back to compiling C code. That would need a compiler the slim image doesn't have, and it would make the build depend on whatever headers happen to be installed. The cache mount keeps pip's download cache between builds without putting it in a layer.
The final stage starts again from python:3.15-slim and copies only /opt/venv and app.py. pip's cache, the build stage's temporary files and the requirements file stay behind. You can copy a venv between stages because a venv isn't self-contained: its python is a symlink to the base image's interpreter, here /usr/local/bin/python3.15. So build and run stages must use the same base image (ideally the same digest). A venv built on python:3.13-slim and copied into a 3.15 image points at an interpreter that doesn't exist.
PATH puts the venv first, so gunicorn and python resolve there without an activate script. PYTHONUNBUFFERED=1 is covered below. PYTHONDONTWRITEBYTECODE=1 stops Python from trying to write .pyc files into directories the app user can't write anyway. USER 10001:10001 is numeric. No user of that ID exists in the image and none is needed, and Kubernetes runAsNonRoot can verify a number. Nothing in /app or /opt/venv belongs to it, so the process can't modify its own code. "Run as non-root" (Advanced container security) covers choosing these IDs and matching volume ownership.
The slim image has no curl or wget, so the health check uses Python itself. urllib.request.urlopen raises on a refused connection or an HTTP error status, which makes the command exit non-zero. Gunicorn binds 0.0.0.0 because you can't reach a server bound to 127.0.0.1 inside the container through a published port. --no-control-socket switches off a feature new in gunicorn 26: a Unix control socket the master creates under $XDG_RUNTIME_DIR or $HOME/.gunicorn. A numeric user with no passwd entry gets HOME=/, which the app user can't write. Without the flag, gunicorn logs Control server error: [Errno 13] Permission denied: '/.gunicorn' at startup (a first run of this lab did). A container is managed with signals anyway. Two workers suit the lab. For real traffic, start near two per CPU core available to the container and measure. For an ASGI app (FastAPI, Starlette), swap the command for uvicorn with --workers, or keep gunicorn with uvicorn worker processes. The rest of the recipe stays the same.
You keep the build context small with an allow-list instead of a deny-list:
# Only the Dockerfile's COPY sources are needed; keep everything else out of the build context**!app.py!requirements.txt
Everything's excluded except the two files the Dockerfile copies. So a local .venv built for another OS, .git, __pycache__ or an .env with credentials can't end up in the image by accident. When you add a file to a COPY, add it here too; the build fails loudly if you forget.
Build and run
name: lab-ordersservices:web:build: .image: lab-orders-web:1ports:- "127.0.0.1:8000:8000"restart: unless-stopped
Compose builds the image, names it lab-orders-web:1, and publishes port 8000 on the VM's loopback address only. --no-cache shows you a first, uncached build:
Step #11 is the hash-checked install: eight wheels, including the aarch64 build of markupsafe (an amd64 machine downloads the x86_64 wheel from the same lock). It took 5.1 seconds. The elided lines are BuildKit housekeeping, such as fetching the docker/dockerfile:1 frontend. On a slow or VPN link (an MTU below Docker's 1500-byte bridge), pip prints Retrying warnings here and the step takes far longer. The lab README's troubleshooting section has the fix.
--wait returned once the image's HEALTHCHECK reported healthy. Compose uses the image's health check when the service defines none. The worker PID varies between runs and requests. Sizes vary with the base image version: the app adds about 28 MB on top of python:3.15-slim, almost all of it the venv.
What is inside the container
The process runs as 10001 with no name and no supplementary groups, and HOME is / because no passwd entry exists for that ID. There's no curl, wget or compiler. The venv's python resolves to the base image's interpreter, which is the dependency described above. The venv still has its own pip. If you don't want the running image to be able to install packages, delete /opt/venv/bin/pip* at the end of the build stage. The health check passed, and Docker keeps the last five results. The logs show gunicorn's own messages, the access log lines from --access-logfile -, and the served / lines from print().
Slim, Alpine or distroless
Older advice says Python on Alpine is slow to build because PyPI has only glibc wheels. That's out of date: musllinux wheels (PEP 656) exist for most popular packages, including NumPy, pandas and psycopg's binary package. The remaining arguments against Alpine for Python are narrower. Some packages still publish only manylinux wheels, and with --only-binary=:all: the build then fails instead of compiling. The DNS resolver and memory allocator in musl behave differently from glibc's, which shows up under load. And the slim image is usually only a few tens of megabytes larger. Use slim unless you've measured a reason not to. A distroless Python base removes the shell and package manager as well. "Minimal bases: distroless, scratch, static and Alpine" (Advanced container security) covers the trade-offs and how you debug such images.
PYTHONUNBUFFERED
When stdout is a pipe, which it is under Docker without -t, Python buffers print() output in blocks of several kilobytes. Gunicorn's own messages go to stderr through the logging module, which flushes each record, so you see them in docker logs either way. To see the difference, run the image with the variable set to an empty string, which turns the setting off, and without the access log:
The request was answered, but its print() line was nowhere in the log. It showed up only when docker stop made the worker exit and flush its buffer. If the container had been killed with SIGKILL (an OOM kill, docker kill), the line would be gone. The access log matters for the comparison. With --access-logfile -, gunicorn writes each access line to the same stdout stream and flushes it, which pushes earlier print() output out too. So a buffering problem can hide until someone turns the access log off. Set PYTHONUNBUFFERED=1 in the image, and send app messages through the logging module rather than print().
Clean up. --rmi all removes the image built for the service:
Hands-on
Before the cleanup step, rerun the buffered step with a new container name and port, and add --access-logfile - to its gunicorn command. Predict whether the served / line now shows up in docker logs before you stop the container, using the access-log paragraph above. Then check it, and remove that container with docker rm -f.
Takeaway
Pin and hash every dependency, and let --require-hashes turn a swapped file into a build failure. The tampered line here stopped pip with THESE PACKAGES DO NOT MATCH THE HASHES before any gunicorn code ran. In production images also set PYTHONUNBUFFERED=1, because the unbuffered test showed no print() output in the log yet after a served request, and a SIGKILL would have lost that line for good.
THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE for gunicorn 26.2.0, although nobody changed requirements.txt. What does that tell you?FROM python:3.13-slim AS build and keeps the final stage on python:3.15-slim. What breaks?/opt/venv/bin/python resolving to /usr/local/bin/python3.15.PYTHONUNBUFFERED set to an empty string by a platform default. Which symptom do you expect in docker logs?served / line missing after the request and appearing only when docker stop let the worker flush its buffer.