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
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 devbinds to127.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 runbinds to0.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:
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:
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:
.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:
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 everydocker compose up --build. No registry round-trip during local development. -
depends_on: { db: { condition: service_healthy } }waits for thedbservice'spg_isreadyhealthcheck to pass before startingweb. 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. IfJWT_SECRET_KEYisn't set in the host shell whendocker compose upruns, compose refuses to start and prints the error message. No silent fallback to a weak default.
To run the stack:
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.