Docker in Practice #1: Containerizing FastAPI — uv, Multi-stage, non-root
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.
uv init fastapi-docker-demo
cd fastapi-docker-demo
uv add fastapi 'uvicorn[standard]'Then 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.
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.
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.
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:8000means “host 8000 → container 8000.”EXPOSE 8000doesn’t auto-map (Basics #4).
This image works, but three things are worth fixing before production:
pip installis hardcoded into theDockerfile, so deps aren’t pinned- Build-time artifacts (no
gcchere yet, but the cache is inefficient) and runtime are in the same layer - 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.
fastapi-docker-demo/
├── app/
│ └── main.py
├── pyproject.toml
├── uv.lock
└── Dockerfileuv 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.
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:
- Copy
pyproject.toml+uv.lockfirst, install deps — this layer caches as long as deps don’t change. - 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).
# ─── 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 forcecopy.UV_COMPILE_BYTECODE=1— pre-compiles.pycso the first request responds faster.- The runtime stage doesn’t have
uvitself — no need to ship it since we don’t reinstall at runtime. PYTHONUNBUFFERED=1makes Docker logs flow live — without it, print/logging can be buffered and invisible.
Compare image sizes:
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.
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—--systemputs 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 (subsequentRUNandCMD) runs asapp.
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:appthose directories. Pin specific directories rather thanchown -Rto 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.
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:
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
# healthyDocker 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:
# ─── 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:
.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:
| Place | When | Use for |
|---|---|---|
ENV (Dockerfile) | Build / runtime | Stable defaults (PYTHONUNBUFFERED, etc.) |
--env / -e (docker run) | Runtime | Per-environment values (DATABASE_URL, API_KEY) |
--env-file | Runtime | A 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.
docker run -d -p 8000:8000 \
-e DATABASE_URL="postgresql://..." \
--env-file .env.production \
fastapi-demoBuild 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
COPYfor 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
uvin runtime. USER appis the one-line non-root switch. Just remember non-root can’t open ports below 1024.HEALTHCHECKdoesn’t trigger Docker’s own restart, but ECS / Compose / K8s use it to swap containers.- Secrets go through
--env/--env-file/--secret, notENV.
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.