Docker in Practice #1: Containerizing FastAPI — uv, Multi-stage, non-root

8 min read

The last series of the Docker track — In Practice. We take the tools built across Basics, Intermediate, and Advanced (18 posts) and apply them, project by project, into shapes you can actually deploy.

This series is Docker in Practice, 6 posts.

  • #1 Containerizing FastAPI — uv, multi-stage, non-root ← this post
  • #2 Django + PostgreSQL compose setup
  • #3 React/Next.js build container
  • #4 Building images in CI — GitHub Actions
  • #5 Registry push and tag strategy
  • #6 Cloud deploy — Fly.io / Railway / ECS

This post traces containerizing a FastAPI app from start to finish. We start with the simplest Dockerfile and end with an operations-friendly image with multi-stage + non-root + HEALTHCHECK. You don’t need to follow the FastAPI series directly — we write a minimal app from scratch.

Starting point — a small FastAPI app #

First, get the app to containerize. Create a fresh project with uv and install just FastAPI and uvicorn.

Project setup
uv init fastapi-docker-demo
cd fastapi-docker-demo
uv add fastapi 'uvicorn[standard]'

Then app/main.py:

app/main.py
from fastapi import FastAPI

app = FastAPI(title="docker demo")


@app.get("/")
def root() -> dict[str, str]:
    return {"message": "hello from container"}


@app.get("/healthz")
def healthz() -> dict[str, str]:
    return {"status": "ok"}

/healthz is intentionally separated. It’s where HEALTHCHECK hits and where a cloud LB looks.

Run it locally once to confirm it works — gives you a baseline when something breaks inside the container.

Run locally
uv run uvicorn app.main:app --reload
# http://127.0.0.1:8000/ → {"message":"hello from container"}

Simplest Dockerfile — the baseline #

Ignore anything operations-friendly for now; just the shortest Dockerfile that runs.

Dockerfile (simple)
FROM python:3.14-slim

WORKDIR /app
COPY . .
RUN pip install fastapi 'uvicorn[standard]'

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Build → run → check.

Build and run
docker build -t fastapi-demo .
docker run -p 8000:8000 fastapi-demo
# In another terminal
curl localhost:8000/
# {"message":"hello from container"}

Two things to note:

  • Without --host 0.0.0.0, the server binds only to 127.0.0.1 inside the container — unreachable from the host. Always 0.0.0.0 to expose outside the container.
  • Port mapping -p 8000:8000 means “host 8000 → container 8000.” EXPOSE 8000 doesn’t auto-map (Basics #4).

This image works, but three things are worth fixing before production:

  1. pip install is hardcoded into the Dockerfile, so deps aren’t pinned
  2. Build-time artifacts (no gcc here yet, but the cache is inefficient) and runtime are in the same layer
  3. The container runs as root with no healthcheck

We tackle them one at a time.

Locking deps with uv — leverage uv.lock #

uv init already produced pyproject.toml and uv.lock. Bring them into the build and dependencies are naturally locked.

Project structure
fastapi-docker-demo/
├── app/
│   └── main.py
├── pyproject.toml
├── uv.lock
└── Dockerfile

uv works fine inside Docker. There’s an official image (ghcr.io/astral-sh/uv) but here we use a small trick — copy just the uv binary onto python:3.14-slim. The base is the familiar Python image, easier to debug.

Dockerfile (with uv)
FROM python:3.14-slim

# Copy just the uv binary
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/

WORKDIR /app

# 1) Copy dependency definitions first → cache efficiency
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

# 2) Then the code
COPY . .
RUN uv sync --frozen --no-dev

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

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

The key is splitting COPY and RUN into two stages:

  1. Copy pyproject.toml + uv.lock first, install deps — this layer caches as long as deps don’t change.
  2. Copy code afterwards — code changes alone keep the deps install cached.

Reverse the order and uv sync runs from scratch on every code change — builds slow down by minutes. Same principle as Intermediate #2 build cache, now in practice.

--no-dev excludes dev deps (tests, linters). --frozen requires versions matching the lock file exactly. Always on for CI / production.

Slimming with multi-stage #

Right now the image after uv sync is also the runtime image. Build-time caches (~/.cache/uv) are baked in unnecessarily. Multi-stage separates build from runtime (Intermediate #1).

Dockerfile (multi-stage)
# ─── 1. builder ─────────────────────────────
FROM python:3.14-slim AS builder

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/

WORKDIR /app

ENV UV_LINK_MODE=copy \
    UV_COMPILE_BYTECODE=1

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

COPY . .
RUN uv sync --frozen --no-dev

# ─── 2. runtime ─────────────────────────────
FROM python:3.14-slim AS runtime

WORKDIR /app

# Bring just the venv and code from builder
COPY --from=builder /app /app

ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONUNBUFFERED=1

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

What changes:

  • Both stages use the same base (python:3.14-slim). The Python interpreter path the venv points to must match. Different bases can break the venv.
  • UV_LINK_MODE=copy — uv normally hardlinks from cache to venv; hardlinks break across stages, so force copy.
  • UV_COMPILE_BYTECODE=1 — pre-compiles .pyc so the first request responds faster.
  • The runtime stage doesn’t have uv itself — no need to ship it since we don’t reinstall at runtime.
  • PYTHONUNBUFFERED=1 makes Docker logs flow live — without it, print/logging can be buffered and invisible.

Compare image sizes:

Image size
docker build -t fastapi-demo:multi .
docker images fastapi-demo
# fastapi-demo  multi    ...   ~120MB (smaller as caches drop out)

Run as non-root #

By default the process inside the container runs as root. Doesn’t directly reach the host, but if the container is breached, root privileges are immediately available — a problem (Advanced #3 image security).

Create a user in the runtime stage and switch to it.

Add non-root
FROM python:3.14-slim AS runtime

# Create non-root user
RUN groupadd --system app && useradd --system --gid app --no-create-home app

WORKDIR /app

COPY --from=builder --chown=app:app /app /app

ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONUNBUFFERED=1

USER app

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Three additions:

  • groupadd + useradd--system puts the UID in system range (< 1000) and writes /etc/passwd.
  • COPY --chown=app:app — sets ownership during copy. Without it, root-owned files aren’t writable by non-root.
  • USER app — every command from here on (subsequent RUN and CMD) runs as app.

Common gotchas:

  • Ports below 1024 (80, 443) can’t be opened by non-root. Run uvicorn on a higher port like 8000 and let the LB / proxy own 80/443.
  • If your app writes to disk (log files, uploads), chown app:app those directories. Pin specific directories rather than chown -R to keep the layer small.

HEALTHCHECK — alive vs. hung #

Docker considers a container “running” while PID 1 is alive. But PID 1 can be alive yet unable to respond — deadlocks, dropped DB connections, hung external API calls. HEALTHCHECK distinguishes those.

Add HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz').read()"

Options:

  • --interval=30s — check every 30 seconds
  • --timeout=3s — fail if no response in 3 seconds
  • --start-period=10s — failures during the first 10 seconds don’t count (warm-up)
  • --retries=3 — three consecutive failures flip to unhealthy

Slim images don’t have curl, so we use python -c for the HTTP call. Cleaner alternatives include adding httpx as a dev dep with a short healthcheck script.

Check status:

View health status
docker run -d --name api -p 8000:8000 fastapi-demo
docker ps
# STATUS column shows (health: starting) → (healthy)

docker inspect --format='{{.State.Health.Status}}' api
# healthy

Docker itself doesn’t auto-restart on unhealthy. Restart belongs to the restart policy (Advanced #6); usually ECS / Kubernetes / Compose use this signal to swap containers.

The completed Dockerfile #

The pieces, all together:

Dockerfile (final)
# ─── 1. builder ─────────────────────────────
FROM python:3.14-slim AS builder

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/

WORKDIR /app

ENV UV_LINK_MODE=copy \
    UV_COMPILE_BYTECODE=1

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

COPY . .
RUN uv sync --frozen --no-dev

# ─── 2. runtime ─────────────────────────────
FROM python:3.14-slim AS runtime

RUN groupadd --system app && useradd --system --gid app --no-create-home app

WORKDIR /app

COPY --from=builder --chown=app:app /app /app

ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONUNBUFFERED=1

USER app

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz').read()"

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Add a .dockerignore and the build context is tidy:

.dockerignore
.git
.venv
__pycache__/
*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
node_modules
.DS_Store
.env
.env.local

.venv is especially important — without it, your host’s venv is copied wholesale into the container, breaking or slowing the build (Basics #6 .dockerignore).

Handling environment variables — ENV for build, --env at run #

Two places to feed config to a container:

PlaceWhenUse for
ENV (Dockerfile)Build / runtimeStable defaults (PYTHONUNBUFFERED, etc.)
--env / -e (docker run)RuntimePer-environment values (DATABASE_URL, API_KEY)
--env-fileRuntimeA whole .env file

Never put secrets like DATABASE_URL in ENV — they end up in image layers in plaintext, visible via docker history or docker inspect.

Runtime env vars
docker run -d -p 8000:8000 \
  -e DATABASE_URL="postgresql://..." \
  --env-file .env.production \
  fastapi-demo

Build context and common pitfalls #

If something goes wrong, here are the most common culprits:

Container exits immediately — almost always missing --host 0.0.0.0, or CMD exits for some reason. Check stderr with docker logs <container>.

uv sync runs from scratch every time — verify pyproject.toml / uv.lock are copied before code. Or check whether some earlier RUN change is invalidating the cache.

Non-root permission errors — the directory the app writes to isn’t owned by app. Use COPY --chown=app:app or RUN chown -R app:app /needed/path.

Image built on Apple Silicon won’t run on a cloud (amd64) — platform mismatch. docker buildx build --platform linux/amd64,linux/arm64 ... (Advanced #2 multi-arch).

Wrap-up #

  • The operations-friendly shape for FastAPI containerization: uv + multi-stage + non-root + HEALTHCHECK.
  • Splitting COPY for dependency definitions and code in two steps keeps the build cache alive.
  • The builder stage builds the venv; the runtime stage only copies the venv and the code. No need to ship uv in runtime.
  • USER app is the one-line non-root switch. Just remember non-root can’t open ports below 1024.
  • HEALTHCHECK doesn’t trigger Docker’s own restart, but ECS / Compose / K8s use it to swap containers.
  • Secrets go through --env / --env-file / --secret, not ENV.

In the next post (#2 Django + PostgreSQL compose), we move from one container to bringing several up as a unit. A Django app and PostgreSQL go into a docker compose file, with migrations / healthcheck / volumes shaped for production.

X