FastAPI

Dockerizing FastAPI (Multi-Stage Build + Compose)

Thirdy Gayares
16 min read

šŸŽÆ 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
  • .dockerignore and 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.py

The 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).

šŸ’”
Ang importante dito: the goal is a small, reproducible image. Smaller images build faster, push faster, start faster, and expose less attack surface. Every choice below serves "small and reproducible."

The Problem: The Naive Dockerfile (See It First)

Most first Dockerfiles look like this — and they're bloated, insecure, and slow to rebuild:

Dockerfile (naive — don't ship this)
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
ProblemWhy it's bad
Base python:3.11~1GB; ships compilers and tools you don't run
COPY . . firstAny code change busts the pip cache — full reinstall every build
Runs as rootA container breakout runs as root on the host
No .dockerignoreBakes in .git, .venv, __pycache__, secrets
No healthcheckOrchestrators can't tell if the app is actually alive
āš ļø
Why this hurts: big images cost money and time on every push/pull, root containers are a security risk, and copying everything first means every one-line code change triggers a full dependency reinstall. We fix all five.

Step 1: .dockerignore

Just like .gitignore, this keeps junk out of the build context (and out of your image). Add it before anything else.

.dockerignore
.git
.gitignore
.venv
venv
__pycache__
*.pyc
.pytest_cache
.mypy_cache
.env
.env.*
*.md
Dockerfile
docker-compose.yml
āš ļø
Security: ignoring .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.

Dockerfile
# ---- 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.

Dockerfile (runtime stage — add before CMD)
# ... 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"]
āœ…
Least privilege: now the app runs as 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.
āš ļø
Order matters: if you 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:

app/main.py
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:

Dockerfile (add before USER/CMD)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
šŸ’”
We probe with Python's stdlib instead of curl — 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.

docker-compose.yml
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:
āš ļø
Host is 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.
šŸ’”
The named volume pgdata persists your database across docker compose down. Without it, every restart starts with an empty database.

Build & Run

1Build the image and check its size
$ 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
2Run the full stack with Compose
$ 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
healthy
āœ…
You did it: a ~180MB non-root image, fast cached rebuilds, a reporting healthcheck, and a one-command API + Postgres stack. Tear it all down with docker compose down.

Production Notes

TopicFor real deployments
WorkersRun multiple Uvicorn workers (--workers 4) or Gunicorn with Uvicorn workers; size to CPU cores
Config & secretsInject via env vars / a secrets manager — never bake .env into the image
Image tagsTag with a version or git SHA (fastapi-app:1.4.2), not just latest
MigrationsRun alembic upgrade head as a startup step / init job, not inside the image build
Pin versionsPin the base image digest and pin deps in requirements.txt for reproducible builds
Reverse proxy / TLSTerminate HTTPS at a proxy (Nginx/Traefik) or the platform load balancer
šŸ’”
Workers example: for production the CMD often becomes 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)

MistakeSymptomFix
Copying code before pip installFull dependency reinstall on every code changeCopy requirements.txt and install first
Using the full python:3.11 base~1GB image, slow push/pullUse -slim + multi-stage
Running as rootSecurity review failure; risky breakoutCreate and USER appuser
No --host 0.0.0.0App unreachable from outside the containerBind to 0.0.0.0, not 127.0.0.1
Using localhost for the DB in ComposeConnection refusedUse the service name (db)
Baking .env / secrets into the imageSecrets leak via the registryIgnore .env; pass env at runtime
āš ļø
The 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

āœ…
šŸš€ Recommended next reads:
  • fastapi-celery-redis — add worker + Redis services to this Compose stack
  • fastapi-async-sqlalchemy — the API service talking to the Postgres container
  • fastapi-alembic-migrations — run migrations as a startup step, not at build time
  • fastapi-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.

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.