On February 11, 2019, researchers disclosed CVE-2019-5736, a CVSSv3 7.2 flaw in runc — the low-level runtime underneath Docker, containerd, and CRI-O — that let a malicious or compromised container overwrite the host's runc binary and execute code as root on the host itself. The attack required only one precondition that a huge fraction of production containers met by default: the process inside the container was running as root. Palo Alto Networks' Unit 42 and AWS both published guidance in the aftermath, and the fix that actually neutralizes this entire class of container-escape bug isn't a runc patch — it's not running your application as root in the first place. Most Python Dockerfiles still do. The official python:3.11 and python:3.12 base images run every process as UID 0 unless you explicitly add a USER instruction, and a naive pip install on top of that base pulls in compilers, headers, and a full apt package cache that never needs to exist in production. This post walks through the concrete Python Dockerfile best practices — multi-stage builds, non-root users, minimal final-stage bases, and pinned dependency installs — that close off that attack surface for real Python services, plus how to keep the resulting image patched after you ship it.
Why does running as root inside a container matter if the container is already isolated?
Container isolation is not a security boundary on its own — namespaces and cgroups reduce blast radius, but a root process inside a container is still root with respect to everything the kernel doesn't explicitly wall off, including any escape path a runtime bug opens. CVE-2019-5736 is the canonical proof: it exploited a race condition in how runc handled its own binary's file descriptor when a container process started as root, letting an attacker who could execute exec inside the container overwrite /proc/self/exe on the host runc and gain host code execution. Rapid7 catalogued it as a full container-breakout bug, not a theoretical one. The mitigation Palo Alto Networks and AWS both recommended was user namespace remapping combined with never running the container's own process as root, because a non-root process inside the container has no path to overwrite a host-owned binary even if the underlying runtime bug exists. The Dockerfile USER instruction has existed since Docker's earliest 1.0-era releases specifically for this purpose, yet it remains one of the most commonly skipped lines in production Python images.
What does a multi-stage Dockerfile for Python actually look like?
Among Python Dockerfile best practices, multi-stage builds are the single highest-leverage change, because they separate what's needed to build your dependencies from what's needed to run them.
Multi-stage builds, introduced in Docker Engine 17.05 in May 2017, let a Dockerfile define more than one FROM stage and copy only the finished artifacts from the build stage into a clean final stage — the compilers, headers, and cached wheel files never reach production. A practical pattern:
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --require-hashes -r requirements.txt --target=/deps
FROM python:3.12-slim
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY --from=builder /deps /usr/local/lib/python3.12/site-packages
COPY --chown=appuser:appuser . .
USER appuser
CMD ["python", "app.py"]
The builder stage can install gcc, libpq-dev, or whatever native extensions your dependencies need to compile; none of that ever lands in the shipped image. The final stage starts from python:3.12-slim, adds a dedicated non-root user, and copies in only the resolved packages and application code. This single pattern addresses both problems at once: a smaller attack surface (fewer binaries an attacker can use post-compromise) and no root process to abuse.
How much further should the final-stage image go — is distroless worth it?
Google's distroless images (gcr.io/distroless/python3) and comparable minimal bases from Chainguard go a step past -slim by removing the shell, package manager, and most coreutils entirely, so a remote-code-execution bug in your application code doesn't hand an attacker a shell, curl, or apt to pivot with. This is a real, actively maintained hardening pattern, not a theoretical one — Google documents distroless as explicitly designed to minimize the tools available after a compromise, on the premise that an attacker who can't invoke a shell has a much harder time establishing persistence or exfiltrating data. The tradeoff is operational: debugging a distroless container requires attaching an ephemeral debug container (kubectl debug in Kubernetes, or a sidecar approach elsewhere) rather than docker exec -it ... /bin/bash, since there is no /bin/bash to exec into. For teams not ready for that workflow change, python:3.12-slim plus a non-root USER and multi-stage build already removes most of the practical risk; distroless is the next increment for teams that want to go further.
What pip-level practices reduce image bloat and supply-chain risk?
Two pip flags matter more than most Python developers realize: --no-cache-dir prevents pip from persisting the download/wheel cache into an image layer, which otherwise silently inflates image size across every dependency you've ever installed during iterative builds. More importantly for supply-chain integrity, pip install --require-hashes forces every package in requirements.txt to match a pinned SHA-256 hash recorded in the file, which pip's own documentation describes as protection against a compromised index or mirror serving a tampered package under a version number you already trust. This closes a gap that a bare version pin (requests==2.31.0) does not: a version pin trusts whatever bytes the index serves for that version string, while a hash pin verifies the exact bytes. Combined with a multi-stage build where the hash verification happens in the disposable builder stage, you get both a smaller final image and a build that fails loudly if a dependency has been swapped.
How does Safeguard fit into hardening and maintaining these images?
Hardening a Dockerfile once is the easy half of the problem; the base image and every dependency inside it keep shipping new CVEs after you ship the container. Safeguard's Docker Hub integration connects directly to public or private Docker Hub repositories to generate an SBOM and run vulnerability scans against the exact image you built — the same python:3.12-slim base referenced above, plus every package layered on top of it — without requiring a separate scanning pipeline. When a new CVE lands on that base image or on a transitive dependency inside it, Safeguard's self-healing containers capability can detect the finding, generate a patch plan (an upstream version bump, a backport, or a Gold-hardened substitution), rebuild the image against your existing test suite, and promote the patched image under advisory, staging-gated, or fully autonomous modes depending on how much automation your team is comfortable granting. Median time-to-heal across customer tenants runs 20 to 45 minutes from CVE publication to production rollout, which turns "we'll patch it in the next release" into a same-day event.