Python images have their own set of pitfalls: bloated base images, compilers pulled in for a single wheel, missing log output, and localhost-only servers that are unreachable from outside the container. This lesson builds a production-ready image for a FastAPI service step by step, explains each Python-specific setting, and shows the equivalent for Flask and for the uv package manager.
A minimal FastAPI service with a health endpoint, which the healthcheck and Compose will rely on:
# app/main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/products")
def products():
return [{"id": 1, "name": "Notebook"}]# requirements.txt
fastapi==0.115.6
uvicorn[standard]==0.34.0Pin versions in requirements.txt (or use a lock file) so the image builds identically next month.
| Tag | Size (approx.) | Notes |
|-----|----------------|-------|
| python:3.13 | 1 GB | Full Debian with compilers; use only as a build stage |
| python:3.13-slim | 150 MB | Debian without build tools; the usual runtime choice |
| python:3.13-alpine | 50 MB | musl libc; many wheels must compile from source, slow builds and occasional incompatibilities |
slim is the default recommendation. If a dependency needs compilation (for example psycopg2 without the binary wheel), install build-essential in the builder stage only, so the runtime stays small.
The sample code at the top uses two stages. The builder creates a virtual environment at /opt/venv and installs dependencies with a pip cache mount so repeat builds do not re-download wheels. The runtime stage copies only the virtual environment and the source. The environment variables matter:
PYTHONUNBUFFERED=1 flushes stdout immediately, so docker logs shows output in real time instead of after the buffer fills.PYTHONDONTWRITEBYTECODE=1 skips .pyc files, which add nothing in a read-only container.PATH="/opt/venv/bin:$PATH" makes uvicorn and python resolve to the virtual environment without activation.The server binds to 0.0.0.0, not 127.0.0.1: inside a container, localhost is the container itself, and a server bound to it is unreachable through -p. Add a .dockerignore with __pycache__/, *.pyc, .venv/, .pytest_cache/ and .env.
docker build -t shop-api:1.0 .
docker run --rm -p 8000:8000 shop-api:1.0
curl http://localhost:8000/health # {"status":"ok"}For development, keep the same image but override the command and mount the source:
services:
api:
build: .
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
ports: ["8000:8000"]
volumes:
- ./app:/app/app--reload watches the mounted directory and restarts on change. Never use it in production: it is single-process and slower.
uvicorn alone runs one worker. For multiple CPU cores, either run several Uvicorn workers or put Gunicorn in front as the process manager:
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
# or, with gunicorn in requirements.txt:
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-w", "4", "-b", "0.0.0.0:8000", "app.main:app"]A Flask application uses Gunicorn directly: CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "app:app"]. In both cases keep the worker count proportional to the CPU limit you give the container; four workers under --cpus 1 only add memory.
uv installs dependencies from a lock file far faster than pip and is available as a static binary you can copy into the build stage:
FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
COPY app ./app
# runtime stage copies /app/.venv and sets PATH="/app/.venv/bin:$PATH"--frozen refuses to build if the lock file is out of date, which is exactly what you want in CI.
127.0.0.1, then concluding that port publishing is broken.PYTHONUNBUFFERED, so logs appear only when the container stops.app user.pip install, so every edit reinstalls dependencies.Why does a container running `uvicorn app.main:app` with the default host refuse connections from `-p 8000:8000`?
python:3.13-slim as the runtime and, if compilation is needed, a builder stage with build tools.PYTHONUNBUFFERED=1, PYTHONDONTWRITEBYTECODE=1 and put the virtual environment on PATH.0.0.0.0; use --reload with a bind mount only in development.uv sync --frozen with a cache mount gives fast, reproducible dependency installation.Next lesson: Containerizing a Java Spring Boot Application — build small, fast-starting JVM images with layered jars.