DEV Community

Prince
Prince

Posted on

Deploying FastAPI on a VPS with Docker Compose: The Production Checklist

Getting FastAPI running in Docker takes five minutes. Getting it to behave well on a real server takes a little longer, because the things that bite you in production rarely show up in the "hello world" tutorial: client IPs that all look the same, migrations racing the app on startup, containers running as root, and logs slowly filling the disk.

This post is the checklist I work through when moving a FastAPI service from a laptop to a single Linux VPS with Docker Compose. It assumes you already have a working app and a server with Docker installed.

1. A Dockerfile that is boring on purpose

FROM python:3.13-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

WORKDIR /app

COPY requirements.txt .
RUN pip install -r requirements.txt

COPY app ./app
COPY alembic.ini .
COPY migrations ./migrations

RUN useradd --create-home --uid 10001 appuser
USER appuser

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \
     "--workers", "2", "--forwarded-allow-ips", "*"]
Enter fullscreen mode Exit fullscreen mode

A few choices worth explaining:

  • Copy requirements.txt first. Dependency installation is cached until that file changes, so code-only changes rebuild in seconds.
  • Pin versions in requirements.txt (fastapi==0.115.6, not fastapi). An unpinned rebuild months later is a different application.
  • Run as a non-root user. If something in your dependency tree is ever exploited, the attacker lands as an unprivileged user inside the container instead of root.
  • Use the exec form of CMD. With the JSON array form, Uvicorn is PID 1 and receives SIGTERM directly, so in-flight requests finish during docker compose down or a redeploy.
  • PYTHONUNBUFFERED=1 makes log lines appear immediately in docker logs instead of arriving in bursts.

On workers: recent Uvicorn versions can supervise several worker processes themselves with --workers, so a separate Gunicorn layer is no longer required for most apps. Start with one or two workers per vCPU and measure. If your endpoints are mostly async and I/O bound, fewer workers than you expect often perform fine.

2. Trust the proxy, and only the proxy

Your app will sit behind a reverse proxy that terminates HTTPS. Without extra configuration, FastAPI sees every request as plain HTTP coming from the proxy container's IP. That breaks redirects (they point to http://), rate limiting, and any audit logging of client IPs.

Uvicorn reads X-Forwarded-For and X-Forwarded-Proto headers, but only from addresses listed in --forwarded-allow-ips. Setting it to * is acceptable only because the API container publishes no ports: the proxy is the only thing that can reach it. If you ever expose port 8000 directly, tighten this to the proxy's network range.

If you mount the API under a path prefix such as /api, also set root_path so the generated OpenAPI docs use the correct URLs.

3. Compose file with health checks and a migration step

services:
  db:
    image: postgres:16.4-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10
    restart: unless-stopped

  migrate:
    image: ghcr.io/acme/api:${API_TAG:?set in .env}
    command: ["alembic", "upgrade", "head"]
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
    restart: "no"

  api:
    image: ghcr.io/acme/api:${API_TAG}
    env_file: .env
    depends_on:
      migrate:
        condition: service_completed_successfully
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)"]
      interval: 10s
      timeout: 5s
      retries: 3
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    restart: unless-stopped

  caddy:
    image: caddy:2
    ports: ["80:80", "443:443"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
    restart: unless-stopped

volumes:
  pgdata:
  caddy_data:
Enter fullscreen mode Exit fullscreen mode

What this buys you:

  • Postgres is actually ready before anything connects, not merely "started". depends_on without a condition only waits for the container to exist.
  • Migrations run exactly once per deploy, in their own short-lived container, before the API starts. If a migration fails, the API does not start on a half-migrated schema, and docker compose logs migrate tells you why.
  • The health check uses Python itself, so you do not need to install curl in a slim image just to probe /health.
  • Log rotation caps each container at about 30 MB of logs. The default JSON log driver has no limit, and "disk full" is one of the most common ways a small VPS falls over.

Your /health endpoint should be cheap. A good pattern is a liveness check that returns immediately, plus an optional readiness check that runs SELECT 1 against the database with a short timeout.

The Caddyfile for the API is two lines, and it handles certificates and HTTP to HTTPS redirects automatically:

api.example.com {
    reverse_proxy api:8000
}
Enter fullscreen mode Exit fullscreen mode

4. Configuration through the environment

Keep secrets and per-environment values in .env on the server (never in Git) and read them with pydantic-settings:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    secret_key: str
    cors_origins: list[str] = []

settings = Settings()
Enter fullscreen mode Exit fullscreen mode

The app fails fast at startup if DATABASE_URL is missing, which is exactly when you want to find out. Note the host in the URL is the Compose service name: postgresql+asyncpg://app:...@db:5432/app. Using localhost here is the classic cause of "connection refused", because inside the API container localhost is the API container.

5. Deploys you can roll back

Build images in CI, tag them with the Git commit SHA, and push them to a registry. On the server, a deploy becomes:

sed -i "s/^API_TAG=.*/API_TAG=3f9c2ab/" .env
docker compose pull
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Rolling back is the same three commands with the previous SHA. Avoid the latest tag in production: when something breaks, you want to know exactly which build is running. Be careful with migrations here, too. A rollback of code is easy; a rollback of a destructive schema change is not, so prefer additive migrations (add a column, backfill, remove the old one in a later release).

For a longer walkthrough that also covers first-time server preparation, domain setup and HTTPS from scratch, this step-by-step guide to deploying FastAPI on a VPS with Docker is a good companion to the checklist here.

6. Back up the database before you need to

The pgdata volume is the only irreplaceable thing on this server. Containers and images can be rebuilt in minutes; data cannot. At minimum, run a nightly pg_dump to storage that lives somewhere other than the VPS, keep a few weeks of history, and actually test a restore once. If you want a concrete setup with scheduling, retention and encryption, see this walkthrough on automating Postgres backups to S3.

The short version

  • Pin versions, run as non-root, use exec-form CMD.
  • Publish no app ports; let the reverse proxy be the only entry point, and configure forwarded headers.
  • Gate startup on a healthy database and a successful migration container.
  • Rotate logs so they cannot fill the disk.
  • Tag images by commit so deploys and rollbacks are one command.
  • Back up Postgres off the server and test the restore.

None of this is exotic, but together it is the difference between an API that runs and an API you can leave alone for months.

Top comments (1)

Collapse
 
dhruv_malaviya_cdcc71e595 profile image
Dhruv Malaviya •

"Client IPs that all look the same" is the one that catches everyone, and --forwarded-allow-ips with a non-root uid 10001 is the right pairing to lead with.

On the client-IP half specifically: if you're behind a managed ingress rather than your own nginx, the header handling is done for you. Krova Cloud passes the visitor's address in X-Real-IP and X-Forwarded-For, spoof-safe rather than the proxy's address. For software that reads the peer from the TCP connection instead of a header, there's a PROXY protocol v1 toggle in the domain settings.

The other two items on your list are worth a note. No public IP by default, so nothing is reachable until you map a port, and each mapping takes an IP allowlist behind a stateful default-deny firewall. And disk is reserved 1:1 with no thin provisioning, so "logs slowly filling the disk" is a real capacity you can size rather than a shared pool that degrades.

Fair caveat: single region in Los Angeles, which matters if your users aren't in the Americas.

Are you running migrations in the entrypoint, or as a separate one-shot container?