CoursesDocker in depthPractical recipes

Recipe: Python app

Slim base, venv, non-root, pinned wheels.

Intermediate12 min · lesson 27 of 30

A Python image done casually is huge and root-owned; done well it is slim, reproducible, and non-root. The recipe combines a slim base, a virtualenv copied out of a builder stage (so the final image has no build toolchain), pinned dependencies, and a dedicated unprivileged user. It applies equally to Flask, FastAPI, Django, or a worker.

Dockerfile
FROM python:3.12-slim AS build
WORKDIR /app
ENV PIP_NO_CACHE_DIR=1
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install -r requirements.txt # pinned; installs into /venv
FROM python:3.12-slim
RUN useradd -r -u 10001 appuser # dedicated non-root user
COPY --from=build /venv /venv # just the venv, no build tools
ENV PATH="/venv/bin:$PATH"
WORKDIR /app
COPY . .
USER appuser
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]

Slim, not alpine, for Python

It is tempting to reach for alpine, but Python on Alpine uses musl libc, which forces many packages with C extensions (numpy, pandas, psycopg) to compile from source — slow builds and subtle bugs. python:3.12-slim (Debian-based) uses glibc and installs the prebuilt manylinux wheels, so it is usually smaller in practice and far less trouble. Pin the minor version and pin your requirements.

Reproducible dependencies

Pin requirements with hashes (pip-tools or uv can generate a fully pinned, hashed requirements file) so pip installs the exact, verified artifacts every build — the Python equivalent of a lockfile. Installing into a venv and copying only that venv into the final stage keeps pip, wheels, and compilers out of the shipped image.

terminal
$ docker build -t payments-py:1.0 .
$ docker run --rm payments-py:1.0 id
uid=10001(appuser) gid=10001(appuser) # non-root, as intended
$ docker run --rm payments-py:1.0 sh -c 'which pip || echo "no pip in final image"'
no pip in final image # build tools stayed in the builder
Bind gunicorn to 0.0.0.0, run as appuser
Two easy misses: a dev server bound to 127.0.0.1 is unreachable through a published port (bind 0.0.0.0), and forgetting USER leaves the app as root. Also skip the Django/Flask dev server in production — use gunicorn/uvicorn with workers. The non-root user plus a slim, pinned image is the baseline every Python service should ship with.