Home/Learn/FastAPI/Dockerizing FastAPI — Images Done Right

Dockerizing FastAPI — Images Done Right

Intermediate
Deployment

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

slim base, cached deps layer, non-root, exec-form CMD
# ── 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 history

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

Service-name DNS, healthcheck-gated startup, one image everywhere
# ── 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 Aria
1

Why does putting COPY . . before pip install make every build slow? Explain layer caching.

MediumBrowserStack
2

Your container ignores SIGTERM and gets SIGKILLed after 30s — the likely Dockerfile bug?

HardAmazon
3

The app races Postgres at compose startup and crashes — two fixes, which is proper?

MediumDunzo

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.

Loading discussion…