šÆ What You Will Build
A small, secure, production-ready container image for FastAPI ā plus a one-command local stack with Postgres. "It works on my machine" becomes "it works in every environment."
- A slim multi-stage Dockerfile
- A non-root user for security
.dockerignoreand layer caching for fast rebuilds- A container healthcheck
- A Docker Compose stack (API + Postgres)
- Production notes: workers, env vars, image tags
Prerequisites: Docker Desktop (or Docker Engine) installed, and a working FastAPI app. Any of the earlier tutorials' apps work ā we assume an app.main:app entrypoint.
fastapi-docker/
āāā .dockerignore
āāā Dockerfile
āāā docker-compose.yml
āāā requirements.txt
āāā app/
āāā __init__.py
āāā main.pyThe Mental Model: Image vs Container
A Docker image is a frozen snapshot of your app plus everything it needs ā Python, your dependencies, your code. A container is a running instance of that image. Build the image once; run identical containers anywhere (your laptop, CI, the cloud).
The Problem: The Naive Dockerfile (See It First)
Most first Dockerfiles look like this ā and they're bloated, insecure, and slow to rebuild:
FROM python:3.11 # full image, ~1GB
COPY . . # copies venv, .git, caches... everything
RUN pip install -r requirements.txt
CMD uvicorn app.main:app --host 0.0.0.0| Problem | Why it's bad |
|---|---|
Base python:3.11 | ~1GB; ships compilers and tools you don't run |
COPY . . first | Any code change busts the pip cache ā full reinstall every build |
| Runs as root | A container breakout runs as root on the host |
| No .dockerignore | Bakes in .git, .venv, __pycache__, secrets |
| No healthcheck | Orchestrators can't tell if the app is actually alive |
Step 1: .dockerignore
Just like .gitignore, this keeps junk out of the build context (and out of your image). Add it before anything else.
.git
.gitignore
.venv
venv
__pycache__
*.pyc
.pytest_cache
.mypy_cache
.env
.env.*
*.md
Dockerfile
docker-compose.yml.env matters ā you do not want secrets baked into an image that gets pushed to a registry. Pass configuration at runtime instead (we do this in Compose).Step 2: A Slim Multi-Stage Dockerfile
A multi-stage build uses one stage to install dependencies and a clean final stage that copies only what's needed to run. Build tools stay in the throwaway stage; the final image stays small.
# ---- Stage 1: builder ----
FROM python:3.11-slim AS builder
# Build a self-contained virtualenv we can copy to the final stage.
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy ONLY requirements first -> this layer caches until deps change.
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# ---- Stage 2: runtime ----
FROM python:3.11-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
WORKDIR /app
# Bring over just the built virtualenv (no compilers, no pip cache).
COPY --from=builder /opt/venv /opt/venv
# Now copy the application code.
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]-slim vs full: the slim base drops the ~700MB of build tooling in the default image. Combined with copying only the venv, your final image is a fraction of the naive one.Step 3: Run as a Non-Root User
By default a container runs as root. If an attacker escapes the process, they're root. Create an unprivileged user and switch to it before the app runs.
# ... runtime stage ...
COPY --from=builder /opt/venv /opt/venv
COPY app ./app
# Create a non-root user and give it ownership of the app dir.
RUN addgroup --system appuser \
&& adduser --system --ingroup appuser appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]appuser, not root. This is a baseline expectation for any production image and often a hard requirement in security reviews.Why Copy requirements.txt First? (Layer Caching)
Docker caches each instruction as a layer and reuses it until an input changes. Because we copy requirements.txt and install before copying the app code, editing a route doesn't reinstall dependencies ā it reuses the cached install layer.
Change a line in app/main.py, then rebuild: => CACHED [builder 4/4] RUN pip install -r requirements.txt # ā reused, instant => [runtime 3/3] COPY app ./app # ā only this reruns Rebuild time: seconds, not minutes.
COPY . . before pip install, every code change invalidates the install layer and reinstalls everything. Always copy dependency manifests first.Step 4: A Healthcheck
First, give the app a cheap health endpoint:
from fastapi import FastAPI
app = FastAPI(title="FastAPI Docker", version="0.1.0")
@app.get("/health")
def health():
return {"status": "ok"}Then tell Docker how to probe it, so orchestrators know when the container is truly ready:
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1curl ā the slim image doesn't include curl, and adding it just for a healthcheck grows the image. Use what's already there.Step 5: Docker Compose with Postgres
Compose runs your API and its dependencies together with one command. Here the API waits for Postgres to be healthy before starting, and config is injected at runtime.
services:
db:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: appdb
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
api:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/appdb
depends_on:
db:
condition: service_healthy # wait until Postgres is ready
restart: unless-stopped
volumes:
pgdata:db, not localhost. Inside the Compose network, services reach each other by service name. The classic bug: ...@localhost:5432 in a container, which points at the container itself, not Postgres.pgdata persists your database across docker compose down. Without it, every restart starts with an empty database.Build & Run
$ docker build -t fastapi-app:latest . ... => => naming to docker.io/library/fastapi-app:latest $ docker images fastapi-app REPOSITORY TAG SIZE fastapi-app latest ~180MB # vs ~1GB for the naive build
$ docker compose up --build
[+] Running 2/2
ā Container fastapi-docker-db-1 Healthy
ā Container fastapi-docker-api-1 Started
$ curl -s http://127.0.0.1:8000/health
{"status":"ok"}
$ docker inspect --format '{{.State.Health.Status}}' fastapi-docker-api-1
healthydocker compose down.Production Notes
| Topic | For real deployments |
|---|---|
| Workers | Run multiple Uvicorn workers (--workers 4) or Gunicorn with Uvicorn workers; size to CPU cores |
| Config & secrets | Inject via env vars / a secrets manager ā never bake .env into the image |
| Image tags | Tag with a version or git SHA (fastapi-app:1.4.2), not just latest |
| Migrations | Run alembic upgrade head as a startup step / init job, not inside the image build |
| Pin versions | Pin the base image digest and pin deps in requirements.txt for reproducible builds |
| Reverse proxy / TLS | Terminate HTTPS at a proxy (Nginx/Traefik) or the platform load balancer |
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4, or Gunicorn managing Uvicorn workers. Keep --reload for local dev only ā never in an image.Common Mistakes (and Fixes)
| Mistake | Symptom | Fix |
|---|---|---|
Copying code before pip install | Full dependency reinstall on every code change | Copy requirements.txt and install first |
Using the full python:3.11 base | ~1GB image, slow push/pull | Use -slim + multi-stage |
| Running as root | Security review failure; risky breakout | Create and USER appuser |
No --host 0.0.0.0 | App unreachable from outside the container | Bind to 0.0.0.0, not 127.0.0.1 |
Using localhost for the DB in Compose | Connection refused | Use the service name (db) |
| Baking .env / secrets into the image | Secrets leak via the registry | Ignore .env; pass env at runtime |
0.0.0.0 gotcha: a common "it runs but I can't reach it" bug is binding Uvicorn to 127.0.0.1. Inside a container that means "only this container" ā you must bind 0.0.0.0 to accept traffic from the host.What's Next
fastapi-celery-redisā add worker + Redis services to this Compose stackfastapi-async-sqlalchemyā the API service talking to the Postgres containerfastapi-alembic-migrationsā run migrations as a startup step, not at build timefastapi-deploymentā take this image to a real host / platform
Recap: multi-stage keeps the image small, copying requirements first keeps rebuilds fast, a non-root user keeps it secure, a healthcheck keeps orchestrators honest, and Compose wires up the whole stack. Build once, run the same everywhere.