Skip to content

Dockerizing a FastAPI App

A Docker image freezes the application, the Python interpreter, and every dependency into a single artifact that runs the same way on a developer laptop, in CI, and in production. This page walks through a small, production-leaning Dockerfile built around uv and python:3.14-slim.

The Dockerfile

Dockerfile
FROM python:3.14-slim

# Copy uv from the official image
COPY --from=ghcr.io/astral-sh/uv:0.11.6 /uv /uvx /bin/

# Compile bytecode for faster startup
ENV UV_COMPILE_BYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PATH="/app/.venv/bin:$PATH"

WORKDIR /app

COPY pyproject.toml uv.lock README.md ./
COPY src ./src
COPY alembic.ini ./
COPY alembic ./alembic
COPY scripts ./scripts

RUN uv sync --frozen --no-dev

EXPOSE 8000

CMD ["fastapi", "run"]

A line-by-line walkthrough:

FROM python:3.14-slim

The Python project's official slim image. It includes the Python interpreter and a minimal Debian userland, but skips compilers and most build tools. The full python:3.14 image is several times larger and rarely necessary at runtime.

COPY --from=ghcr.io/astral-sh/uv:0.11.6 /uv /uvx /bin/

Multi-stage COPY --from pulls the uv and uvx binaries out of Astral's official uv image and drops them into the runtime image. Pinning the uv version (0.11.6) is deliberate: floating tags drift, and a uv upgrade has occasionally changed the lockfile format in ways that break old uv sync invocations.

ENV UV_COMPILE_BYTECODE=1

Tells uv to compile .pyc bytecode files during install. The first request after the container starts skips the compilation cost it would otherwise pay lazily.

ENV PYTHONUNBUFFERED=1

Tells Python not to buffer stdout/stderr. Without it, log lines can sit in a buffer for seconds and never reach the container's log stream when the process crashes.

ENV PATH="/app/.venv/bin:$PATH"

uv creates /app/.venv/ when it runs uv sync. Prepending the venv's bin/ to PATH means fastapi, alembic, and python resolve to the venv's binaries without needing uv run everywhere.

COPY pyproject.toml uv.lock README.md ./

Project metadata and the lockfile. uv sync --frozen (below) refuses to start if the lockfile is missing or out of sync with the toml.

RUN uv sync --frozen --no-dev

--frozen enforces the lockfile exactly: no version drift between your dev machine, CI, and the image.

--no-dev skips the dev dependency group (Ruff, mypy, pytest, coverage).


EXPOSE 8000

Which port the container will listen on. Compose, Kubernetes, and docker run -p use it, but it doesn't actually open the port; the runtime port mapping does.

CMD ["fastapi", "run"]

fastapi run is the FastAPI CLI's production-mode entrypoint. It binds to 0.0.0.0:8000, runs without auto-reload, and (unlike fastapi dev) doesn't enable the debug behavior that's appropriate for a developer workstation.

fastapi dev vs fastapi run

The two commands look similar but are tuned for opposite use cases:

  • fastapi dev binds to 127.0.0.1 (localhost-only), enables auto-reload on code changes, and prints colored, formatted logs. Optimized for the iteration loop on a developer machine.
  • fastapi run binds to 0.0.0.0 (reachable from outside the container), disables auto-reload, and uses production-style logging. Use this one for a deployed server.

To run multiple worker processes, pass --workers:

Docker
CMD ["fastapi", "run", "--workers", "2"]

fastapi run proxies most arguments through to uvicorn under the hood. For full control over timeouts, log format, or to run gunicorn instead, drop down to invoking uvicorn directly:

Docker
CMD ["uvicorn", "release_tracker.main:app", \
     "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]

.dockerignore

A .dockerignore file at the project root keeps the image small by stopping junk from getting copied in:

.dockerignore
.git
.venv
__pycache__
*.pyc
.pytest_cache
.coverage
.mypy_cache
.ruff_cache
htmlcov
.env

.venv matters most. Without the ignore entry, COPY src ./src followed by RUN uv sync --frozen --no-dev would copy in the developer's local virtualenv, then immediately overwrite it. Worse, the local venv would bloat the build context that gets sent to the daemon, slowing every build.

.env is in the list because secrets don't belong in images. The deployment platform supplies them at runtime through environment variables or a secrets service.

Compose for Local Production-Style Runs

Running the image alone is one thing; running it alongside a real Postgres container is a more useful local test. A second compose file, compose.app.yaml, brings up both:

compose.app.yaml
services:
  web:
    build: .
    environment:
      DATABASE_URL: postgresql+psycopg://release_tracker:release_tracker@db:5432/release_tracker
      DEBUG: "true"
      JWT_SECRET_KEY: ${JWT_SECRET_KEY:?Set JWT_SECRET_KEY before running compose.app.yaml}
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy
    command: ["sh", "-c", "alembic upgrade head && fastapi run"]

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: release_tracker
      POSTGRES_USER: release_tracker
      POSTGRES_PASSWORD: release_tracker
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U release_tracker -d release_tracker"]
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
  • build: . rebuilds the image from the current source tree on every docker compose up --build. No registry round-trip during local development.

  • depends_on: { db: { condition: service_healthy } } waits for the db service's pg_isready healthcheck to pass before starting web. Without it, the web container races against Postgres startup and the first migration fails on a cold boot.

  • command: ["sh", "-c", "alembic upgrade head && fastapi run"] applies pending migrations on container startup, then starts the server. Two trade-offs to be aware of:

    • In production, running migrations as part of every container startup couples the deploy to the migration. A bad migration takes the whole rollout down. Larger teams usually run migrations as a separate, gated step.
    • With multiple replicas, every replica races the migration. Alembic's table-level lock keeps that safe, but it's noisier than running migrations once before the rollout starts.
  • JWT_SECRET_KEY: ${JWT_SECRET_KEY:?...} uses the ${VAR:?error} form. If JWT_SECRET_KEY isn't set in the host shell when docker compose up runs, compose refuses to start and prints the error message. No silent fallback to a weak default.

To run the stack:

shell
export JWT_SECRET_KEY=$(uv run python -c "import secrets; print(secrets.token_urlsafe(32))")
docker compose -f compose.app.yaml up --build

The app is reachable at http://localhost:8000.