Docker Python: A Practical Containerisation Guide
TL;DR
This Docker Python guide covers everything from your first image to a production compose stack. Containerising a Python app means shipping a working pip install inside a reproducible image. Pin the base image by patch and Debian codename (python:3.14.5-slim-bookworm, never :latest). Set four env vars: PYTHONDONTWRITEBYTECODE, PYTHONUNBUFFERED, PIP_NO_CACHE_DIR, PIP_DISABLE_PIP_VERSION_CHECK. Use a multi-stage build so compilers stay out of the final image. Run as a non-root user. Add a HEALTHCHECK that hits a real endpoint, not just curl localhost. The rest of this article walks through each of those, with a working compose.yaml and a list of mistakes I have made so you do not have to.
Why use Docker for Python
You tested on your laptop. CI passed. Then production broke because the host had Python 3.11, your code used 3.12 syntax, and cryptography needed a libssl version the server did not have. Docker fixes that by shipping the runtime, the libraries, and the OS bits your code needs as one immutable artifact.
For Python specifically, containers solve three recurring problems:
- Dependency drift between environments.
- C-extension wheels that need system libraries (
libpq,libxml2,gomp). - “Works on my machine” when handing the app to anyone else, including CI and a new laptop.
A container is not magic. It is just a lightweight, portable process. It pays for itself the first time you avoid a half-day of “but it works locally.” See the official Python image page on Docker Hub for the full tag list and current sizes.
Your first Dockerfile
Start with a minimal FastAPI app that returns a JSON health check from one route and a hello message from another:
# app/main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/")
def root() -> dict[str, str]:
return {"message": "hello from container"}
The app has no business logic on purpose. The point is to give uvicorn something to serve, give /health something to return 200 on, and let us focus on the Docker side. Once this runs in a container, swap in your real routes and the Dockerfile does not change.
Pin every dependency in a requirements.txt. Use ==, not >=, so a clean install six months from now gives the same versions that passed tests today:
# requirements.txt
fastapi==0.115.6
uvicorn[standard]==0.32.1
The Dockerfile that builds the image. WORKDIR creates /app and sets it as cwd, so every later COPY is relative to it. COPY requirements.txt . happens before COPY . . on purpose; that ordering is what makes layer caching work:
# Dockerfile
FROM python:3.14-slim-bookworm
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Build the image, run it in the foreground, and curl the health endpoint. The --rm flag removes the container once you kill it so you do not collect a graveyard of stopped containers:
docker build -t myapp:dev .
docker run --rm -p 8000:8000 myapp:dev
curl http://localhost:8000/health
# output: {"status":"ok"}
That is a working image in 10 lines of Dockerfile. The rest of this article is about not painting yourself into a corner with it.
How do I pick a Docker Python base image?
The python image on Docker Hub comes in three flavours, and the choice matters because of how Python packages ship native extensions as manylinux wheels. Pick the variant that matches your constraints on size, compatibility, and how much wheel-rebuild pain you can tolerate:
| Tag | Approx. size | Use when |
|---|---|---|
python:3.14-slim-bookworm | ~150 MB | Default. Same glibc as Debian, most wheels work. |
python:3.14-alpine | ~50 MB | Cold-start sensitive. Musl libc breaks some C wheels. |
python:3.14 | ~1.0 GB | Avoid in production. Convenience for hacking. |
The Alpine variant is tempting because it is a third of the size. The gotcha is musl libc. Pre-built wheels for numpy, pandas, cryptography, psycopg, and most C-extension packages target manylinux, which is glibc. On Alpine, pip falls back to compiling from source, which is slow and often fails without extra apk add lines. You will save 100 MB and spend two hours debugging fatal error: Python.h: No such file or directory.
Pin a specific patch and Debian codename, never a floating tag. The first form below locks the exact Python patch and the Debian base; the second floats with every minor release, and the third floats with every base image rebuild:
FROM python:3.14.5-slim-bookworm
# vs
FROM python:3-slim
# vs
FROM python:latest
For production, pin both the Python patch and the Debian codename so a rebuild two months from now gives the same image. The Docker Hub Python image page lists every supported combination of Python version and Debian codename if you need to confirm what is current.
Which environment variables matter most?
These four go in every Python Dockerfile, and each one solves a real problem that has bitten people in production. The \ at the end of each line is a line continuation in Dockerfile syntax, which lets us set all four vars in a single ENV layer instead of four:
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
PYTHONDONTWRITEBYTECODE=1keeps.pycfiles out of the image. Smaller layers, fewer surprise diffs indocker history.PYTHONUNBUFFERED=1flushes stdout and stderr immediately. Without it, Python buffers when stdout is not a TTY, anddocker logsshows nothing for hours. This single variable has rescued more debugging sessions than any other.PIP_NO_CACHE_DIR=1keepspipfrom leaving/root/.cache/pipbehind in the layer. Thepip install --no-cache-dirflag in the Dockerfile above does the same job, but the env var catches everypipcall you make later, including ad-hoc ones in aRUN.PIP_DISABLE_PIP_VERSION_CHECK=1quiets the “you are using pip 24.x, version 25 is available” line that adds noise to every CI log.
How does layer caching speed up Docker builds?
Docker caches each layer. pip install re-runs only when something earlier in the Dockerfile changes, which means you can usually get a sub-second rebuild by ordering the Dockerfile from least-changed to most-changed. The rule is concrete: copy and install dependencies before you copy source code.
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
If you flip that order, every code change re-installs every dependency. Builds go from 10 seconds to 3 minutes. The cache key is the file hash, not the file content, so this works even when you do not see it.
The other half is .dockerignore at the project root. Without it, COPY . . ships .git, .venv, __pycache__, .pytest_cache, .env, and every editor swap file into the build context. The build context has to be sent to the Docker daemon over the network, so this also slows down every build, not just the final image:
# .dockerignore
.git
.gitignore
.venv
venv
__pycache__
*.pyc
*.pyo
.pytest_cache
.mypy_cache
.ruff_cache
.env
.env.*
*.md
!README.md
Dockerfile
.dockerignore
node_modules
.coverage
htmlcov
dist
build
*.egg-info
I have seen a “small” 200 MB image balloon to 800 MB because someone forgot this file. The *.md line is debatable; I keep it to avoid shipping internal docs that have no business in a runtime image.
What is a multi-stage build?
C-extension packages (psycopg, cryptography, lxml, numpy) need compilers at install time: gcc, libffi-dev, python3-dev. They do not need them at runtime. A multi-stage build puts the compilers in a builder stage and copies only the installed packages into a slim runtime stage, which cuts the final image by 40 to 60 percent. The # syntax=docker/dockerfile:1.7 line enables BuildKit features, including the cache mount further down:
# syntax=docker/dockerfile:1.7
# --- builder ---
FROM python:3.14-slim-bookworm AS builder
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libffi-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt
# --- runtime ---
FROM python:3.14-slim-bookworm AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
RUN groupadd --system app \
&& useradd --system --gid app --create-home --home-dir /app app
COPY --from=builder /install /usr/local
COPY --chown=app:app . /app
USER app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
pip install --prefix=/install lays files out as <prefix>/lib/python3.14/site-packages/.... Copying /install into /usr/local puts them where the system Python looks. The final image is 40 to 60 percent smaller than a single-stage build that keeps the compilers.
For BuildKit (Docker 23+), use a cache mount so wheel downloads survive across builds without bloating the final image. The cache lives in BuildKit’s storage between builds and is not written into any layer:
# syntax=docker/dockerfile:1.7
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
Should I run my container as root?
No. By default, the container runs as root, and a process escape in the container gives an attacker root in the kernel namespace. The fix is one USER line plus a system user created in an earlier RUN. The --system flag avoids creating a password or expiry date, --create-home gives the user a writable home for tools that expect one, and --home-dir /app matches the WORKDIR:
RUN groupadd --system app \
&& useradd --system --gid app --create-home --home-dir /app app
USER app
Two things break when you switch users. First, the app cannot write to its working directory; COPY --chown=app:app . /app fixes that. Second, pip install at runtime fails because the user has no writable home. Install in the builder stage, not at runtime, and you are fine.
How do I write a healthcheck that works?
A healthcheck tells Docker (or Kubernetes, or Compose) whether the container is doing useful work. The default check is “is the process alive?”, which misses deadlock, broken database connections, and half-loaded apps. A real healthcheck hits a route that proves the app is ready, not just running.
In the Dockerfile
The HEALTHCHECK directive accepts flags for the probe interval, timeout, grace period, and how many failures mark the container unhealthy. The one-liner uses urllib instead of curl because the slim Python image does not ship curl or wget:
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').status" || exit 1
HEALTHCHECK NONE disables any inherited one. Skip the healthcheck in unit-test images and the dev docker run.
In compose.yaml
The Compose form takes the same probe options as a list. Putting the healthcheck under the api service is what makes depends_on.condition: service_healthy further down work; without it, the app starts before the database is ready:
services:
api:
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"]
interval: 10s
timeout: 3s
retries: 5
start_period: 20s
The python -c form keeps the image lean; the slim Python image does not ship curl or wget. If you do not mind a larger image, curl --fail http://localhost:8000/health is a shorter command line, and Alpine ships curl by default.
How do I run a multi-service app with docker compose?
Compose is the easiest way to run a multi-service app locally and in CI. The modern file is compose.yaml (Compose Spec), and the v2 CLI is docker compose with a space, not a hyphen. The example below runs the FastAPI app from earlier alongside a Postgres database, with healthchecks on both so depends_on can wait properly:
# compose.yaml
services:
api:
build: .
image: myapp:dev
ports:
- "8000:8000"
env_file:
- .env
environment:
- DATABASE_URL=postgresql+psycopg://app:app@db:5432/app
depends_on:
db:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"]
interval: 10s
timeout: 3s
retries: 5
start_period: 20s
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pgdata:
Two things to notice:
depends_on.condition: service_healthywaits for Postgres to accept connections before starting the app. Without it, you get a race: the app starts, fails to connect, and exits.- Container-to-container DNS uses the service name. Inside the
apicontainer, the database isdb:5432, notlocalhost:5432.localhostis the container itself.
docker compose up --build
curl http://localhost:8000/health
# output: {"status":"ok"}
What mistakes should I avoid?
Most of these come from real incidents. The first three account for maybe 80 percent of “why does my container not work” debugging time. Read them before you spend an afternoon on the same bug.
pip installworks on host, fails in container. Almost always a missing system library. Add the rightapt-get installline in the builder stage:libpq-devfor psycopg,libxml2-dev libxslt1-devfor lxml,libffi-devfor cryptography.- Container exits immediately. Exit code 137 means the OOM-killer fired; bump
--memoryor fix the leak. Code 0 with no output usually means the wrongCMDor the process finished before logging anything; double-check the entrypoint and thatPYTHONUNBUFFERED=1is set. - Logs appear in bursts.
PYTHONUNBUFFERED=1is not set. See the env var section above. - Image is 1.5 GB. Forgot
.dockerignore, usedpythoninstead ofpython:slim, or shippednode_modulesor.gitinto the build context. exec format erroron Apple Silicon. Built on M1/M2 without--platform linux/amd64, deploying to x86. Usedocker buildx build --platform linux/amd64, or build on the target architecture.- Multi-stage build cannot find packages in the runtime stage. Installed to
/installbut copied the wrong path. Usepip install --prefix=/installandCOPY --from=builder /install /usr/local. - uvicorn starts but
docker logsshows nothing. Forgot--host 0.0.0.0. Default uvicorn binds to127.0.0.1, which is unreachable from outside the container’s network namespace. - Container takes 10 seconds to stop. Used shell-form
CMD uvicorn app.main:app. That runs/bin/sh -c, which does not forwardSIGTERMto the Python process. Use exec form:CMD ["uvicorn", "app.main:app"].
Conclusion
That is the working baseline for Docker Python containerisation. The pattern you saw in this article, a python:slim base, a multi-stage build, a non-root user, a real healthcheck, and a Compose file with depends_on.condition: service_healthy, is what most production Docker Python stacks converge on after a few rounds of debugging. From here, four directions are worth exploring next: multi-arch builds for ARM servers, distroless or chainguard/python for even smaller attack surface, Kubernetes or ECS when one Compose file becomes one too many services, and build cache in CI so you are not re-downloading every wheel on every pipeline run.
Containers do not replace good Python packaging. They sit on top of it: a clean requirements.txt and a working pip install are still the foundation. The Dockerfile is just the box you ship that foundation in.
Frequently asked questions
Should I use Alpine for my Python image?
Only if cold start or image size matters more than build time. Alpine uses musl libc, so most pre-built manylinux wheels fall back to compiling from source, which is slow and often fails. For most apps, python:3.14-slim-bookworm is the better default.
Do I need a multi-stage build for a small Flask app?
If your requirements.txt is pure Python, no. The savings come from skipping the compilers, and pure-Python packages do not need them. As soon as you add psycopg, numpy, lxml, or cryptography, the multi-stage build pays for itself.
Why is my container restarting every few seconds?
Usually a crashed process. Run docker logs <container> to see the output, then check for ModuleNotFoundError (wrong image, missing dep), Address already in use (port collision), or a healthcheck that exits non-zero for a reason you did not expect.
How do I pass secrets into a Python container?
Never in an ENV line, because docker history will show them. Use env_file: .env in Compose, Docker secrets in Swarm, or pull from a vault at startup. The .env file should be in .gitignore and .dockerignore.
Can I run a Python container without root on a read-only filesystem?
Yes. Add USER app and docker run --read-only --tmpfs /tmp. The --tmpfs flag gives /tmp an in-memory filesystem, which is where most apps write temporary files. If the app writes elsewhere, add another --tmpfs for that path or relax the read-only flag.
See also
- Virtual Environments — the local counterpart of containers; same idea, different scope.
- Environment Variables — how Python reads configuration in and out of a container.
- Docker SDK for Python — controlling the Docker daemon from inside a Python script.
- FastAPI Quickstart — the framework used in the worked example, in more depth.
- Python Logging — the
PYTHONUNBUFFEREDenv var and stdout logging in more depth.