Recipe: Python app
Slim base, venv, non-root, pinned wheels.
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.
FROM python:3.12-slim AS buildWORKDIR /appENV PIP_NO_CACHE_DIR=1RUN python -m venv /venvENV PATH="/venv/bin:$PATH"COPY requirements.txt .RUN pip install -r requirements.txt # pinned; installs into /venvFROM python:3.12-slimRUN useradd -r -u 10001 appuser # dedicated non-root userCOPY --from=build /venv /venv # just the venv, no build toolsENV PATH="/venv/bin:$PATH"WORKDIR /appCOPY . .USER appuserEXPOSE 8000CMD ["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.
$ docker build -t payments-py:1.0 .$ docker run --rm payments-py:1.0 iduid=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