Containerizing a Python Application with FastAPI

Intermediate
13 min

Containerizing a Python Application with FastAPI

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.

The Application

A minimal FastAPI service with a health endpoint, which the healthcheck and Compose will rely on:

python
# 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"}]
text
# requirements.txt fastapi==0.115.6 uvicorn[standard]==0.34.0

Pin versions in requirements.txt (or use a lock file) so the image builds identically next month.

Choosing the Base Image

| 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 Dockerfile Explained

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.

bash
docker build -t shop-api:1.0 . docker run --rm -p 8000:8000 shop-api:1.0 curl http://localhost:8000/health # {"status":"ok"}

Development with Live Reload

For development, keep the same image but override the command and mount the source:

yaml
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.

Production Server Options

uvicorn alone runs one worker. For multiple CPU cores, either run several Uvicorn workers or put Gunicorn in front as the process manager:

dockerfile
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.

Using uv Instead of pip

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:

dockerfile
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.

Common Mistakes

  • Binding to 127.0.0.1, then concluding that port publishing is broken.
  • Omitting PYTHONUNBUFFERED, so logs appear only when the container stops.
  • Running as root; the sample creates and switches to a dedicated app user.
  • Copying the whole project before pip install, so every edit reinstalls dependencies.
Quick Quiz
Question 1 of 3

Why does a container running `uvicorn app.main:app` with the default host refuse connections from `-p 8000:8000`?

Key Takeaways

  • Use python:3.13-slim as the runtime and, if compilation is needed, a builder stage with build tools.
  • Set PYTHONUNBUFFERED=1, PYTHONDONTWRITEBYTECODE=1 and put the virtual environment on PATH.
  • Bind servers to 0.0.0.0; use --reload with a bind mount only in development.
  • Scale with Uvicorn workers or Gunicorn, sized to the container's CPU limit.
  • 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.

Containerizing a Python Application with FastAPI - Docker | CodeYourCraft | CodeYourCraft