Dockerizing FastAPI — Images Done Right
IntermediateA slim Python base, requirements installed before code (layer-cache order), a non-root user, and uvicorn as CMD — plus docker-compose wiring Postgres/Redis for a one-command dev environment.
Overview
The Dockerfile decisions that matter are few and always the same. Base image: python:3.12-slim — full images drag ~700MB of build tools; alpine trades glibc for musl and breaks wheels (psycopg2, numpy) in exchange for little. Layer order: COPY requirements.txt and pip install BEFORE copying source, so editing code reuses the cached dependency layer and rebuilds take seconds, not minutes. Security: create a non-root user — a container escape from root is a much worse day. Runtime: exec-form CMD running uvicorn, so SIGTERM reaches the process and the graceful shutdown you configured actually happens. docker-compose then declares the whole dev stack — app, Postgres, Redis — with healthchecks gating startup order, and the same image (unchanged!) deploys to Fly/Render/K8s with config arriving via environment, exactly as the Settings chapter designed.
The Dockerfile — Every Line Justified
Nine instructions, each earning its place. The requirements-before-code order is the single biggest quality-of-life optimisation for daily development.
# ── Dockerfile ──────────────────────────────
FROM python:3.12-slim
# slim: ~150MB vs ~1GB full. alpine: musl breaks compiled wheels — avoid.
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# no .pyc litter; logs flush immediately (or docker logs shows nothing on crash)
WORKDIR /app
# deps BEFORE code — the layer-cache money shot:
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# code changes daily; requirements change weekly. This order means the
# 90-second pip layer is CACHED for every code-only rebuild (→ ~2s builds).
COPY app/ ./app
COPY alembic/ ./alembic
COPY alembic.ini .
# non-root: a compromised app process shouldn't own the container
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
# EXEC form (JSON array) — uvicorn IS PID 1 and receives SIGTERM directly.
# Shell form (CMD uvicorn ...) wraps it in /bin/sh, which EATS the signal:
# graceful shutdown never runs, K8s waits, then SIGKILLs. Classic.
# ── .dockerignore (keeps the build context and image clean) ──
# .venv/ .git/ __pycache__/ tests/ .env *.md
# .env especially: baking secrets into image layers = leaked via docker historycompose for Dev, the Same Image for Prod
compose spins the full stack with one command; depends_on + healthcheck fixes the "app starts before Postgres" race. The image never changes between environments — only its environment does.
# ── docker-compose.yml ──────────────────────
services:
api:
build: .
ports: ["8000:8000"]
environment:
DATABASE_URL: postgresql://app:app@db:5432/placement # ← host = service name
REDIS_URL: redis://cache:6379/0
JWT_SECRET: dev-only-secret
depends_on:
db: { condition: service_healthy } # WAIT for the healthcheck,
cache: { condition: service_started } # not just container start
volumes:
- ./app:/app/app # dev hot-reload; REMOVE for prod parity
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: placement
healthcheck: # the fix for "connection refused" on startup
test: ["CMD-SHELL", "pg_isready -U app -d placement"]
interval: 2s
retries: 10
volumes:
- pgdata:/var/lib/postgresql/data # survive docker compose down
cache:
image: redis:7-alpine # alpine fine for prebuilt binaries
volumes:
pgdata:
# docker compose up --build → full stack, one command
# migrations as a release step (Alembic chapter), not in CMD:
# docker compose run --rm api alembic upgrade head
# (on Fly: [deploy] release_command = "alembic upgrade head")
# The environment-parity principle: the SAME image goes to staging and
# prod. Nothing environment-specific is baked in — DATABASE_URL, secrets,
# origins all arrive as env vars (the Settings chapter, completing its arc).Key Points to Remember
- 1python:3.12-slim; skip alpine (musl vs manylinux wheels)
- 2COPY requirements → pip install → COPY code: cached deps = seconds-fast rebuilds
- 3Non-root USER; exec-form CMD so SIGTERM reaches uvicorn (graceful shutdown)
- 4compose healthchecks gate startup; same image every env, config via environment
Interview Questions
Sign in to ask AriaWhy does putting COPY . . before pip install make every build slow? Explain layer caching.
Your container ignores SIGTERM and gets SIGKILLed after 30s — the likely Dockerfile bug?
The app races Postgres at compose startup and crashes — two fixes, which is proper?
Ask Aria about Dockerizing FastAPI — Images Done Right
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.