The Python Dockerfile best practices that pay off most are running the app as a non-root user, starting from a slim official base image, splitting build and runtime with multi-stage builds, and pinning dependencies so your image is reproducible. A Dockerfile is deceptively easy to write and easy to write badly: the naive version works, ships as root, weighs half a gigabyte, and reinstalls the world on every code change. This guide walks through a secure, fast Dockerfile line by line, explaining why each choice matters rather than just handing you a template to paste.
Choose the right base image
Start from an official Python image, and prefer the slim variant for most applications. Slim uses standard glibc, so prebuilt wheels and native extensions work without surprises, while omitting the development tools and packages you do not need at runtime. That keeps the image small and the attack surface narrow.
The alpine variant is even smaller, but it uses musl libc instead of glibc, which means many Python packages have no prebuilt wheel and must compile from source. That slows builds and occasionally breaks on subtle libc differences. Unless image size is your dominant constraint, slim is the pragmatic default.
Pin the version. Use python:3.12-slim, not python:latest, so a rebuild does not silently jump major versions on you.
Use a multi-stage build
Multi-stage builds separate the tools you need to build dependencies from the runtime image that ships. Compilers, headers, and build caches stay in the builder stage and never reach production. This can cut the final image size substantially and removes build tooling that would otherwise expand your attack surface.
# ---- builder stage ----
FROM python:3.12-slim AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# install build deps only in this stage
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ---- runtime stage ----
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# create a non-root user
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
# copy only the installed packages, not the build tools
COPY --from=builder /install /usr/local
COPY . .
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["python", "-m", "myapp"]
Run as a non-root user
This is the single most important security line in the file. By default a container process runs as root, and without user-namespace remapping that root is real root on the host. If the process is compromised, an attacker inherits those privileges and a container escape becomes far more dangerous. Creating a dedicated non-root user and switching to it with USER appuser limits the blast radius of any compromise. The reference above creates appuser with a fixed UID and hands ownership of the app directory to it before dropping privileges.
Order layers for cache efficiency
Docker caches each layer and reuses it until something changes. Copy requirements.txt and install dependencies before copying your application code, because your dependencies change far less often than your source. Ordered this way, editing a Python file reuses the cached dependency layer instead of reinstalling every package, which turns a multi-minute rebuild into a few seconds. The reference Dockerfile does this: COPY requirements.txt and the install happen before COPY . ..
Set the right environment variables
Two environment variables belong in nearly every Python Dockerfile. PYTHONDONTWRITEBYTECODE=1 stops Python from writing .pyc files you do not need in a container. PYTHONUNBUFFERED=1 sends stdout and stderr straight through, so your logs appear in real time instead of being buffered, which matters when you are tailing container logs during an incident.
Keep secrets and junk out of the image
Never bake secrets into a Dockerfile with ENV or ARG; they persist in the image history and anyone who pulls the image can read them. Pass secrets at runtime through the environment or a mounted secret instead. Add a .dockerignore that excludes .git, virtual environments, local .env files, and test artifacts, so they never enter the build context and never leak into the image.
Pin and scan your dependencies
Pin dependency versions in requirements.txt (or a lockfile) so the image is reproducible and a transitive package cannot silently jump to a vulnerable release between builds. Then scan the finished image. Even a perfectly written Dockerfile inherits whatever CVEs live in its base image and its Python packages, and new ones are disclosed daily. Wiring an image and dependency scanner into CI, and rebuilding on a schedule so base-image patches land, keeps a clean Dockerfile from drifting into a vulnerable image over time. For the scanning side of this, see our container image scanner guide.
FAQ
Should I use the slim or alpine Python base image?
Slim for most applications. It uses glibc, so prebuilt wheels and native extensions install without compiling from source. Alpine is smaller but uses musl libc, which forces many packages to build from source and can cause subtle breakage. Choose alpine only when image size is your overriding constraint.
Why run a Python container as a non-root user?
By default the container process runs as root, which maps to real root on the host without user-namespace remapping. Running as a dedicated non-root user limits what an attacker gains if the process is compromised, reducing the risk of a container escape.
Do multi-stage builds really matter for Python?
Yes. They keep compilers and build tooling out of the final image, which shrinks it and reduces the attack surface. For Python apps that compile native extensions, the size and security difference is significant.
How do I keep secrets out of my Docker image?
Never use ENV or ARG for secrets, since they persist in image history. Pass secrets at runtime via the environment or mounted secrets, and add a .dockerignore so local .env files never enter the build context.