· Patryk Komow · 10 min read

Deploy FastAPI with PostgreSQL: Docker, a VPS with Caddy, or Railway

The same Docker image, deployed two ways: on a small VPS with Caddy handling HTTPS, or on Railway with a managed database. Most of the work is in the image and the settings, and most of the bugs are in the few lines between the app and the proxy in front of it.

A production Dockerfile

FROM python:3.12-slim

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

WORKDIR /code

# Install dependencies first, so code changes don't invalidate this layer.
COPY pyproject.toml ./
RUN mkdir app && touch app/__init__.py \
    && pip install . \
    && pip uninstall -y my-saas && rm -rf app

COPY . .

RUN useradd --create-home appuser && chown -R appuser /code
USER appuser

EXPOSE 8000

What each part is for:

Add a .dockerignore with at least .env, .git and your virtualenv. COPY . . copies everything else, and a .env with live keys baked into an image is a leak waiting for someone to push it to a registry.

Migrations on start, and how many workers

# PORT is set by hosts like Railway; WEB_CONCURRENCY sets the number of worker processes.
CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000} --workers ${WEB_CONCURRENCY:-2} --proxy-headers"]

Running alembic upgrade head before the server means a deploy is just "start the new container". If a migration fails, the server never starts, the health check fails, and a platform like Railway keeps the old version running. On a single server it means a short outage instead of a half-migrated app serving errors.

The sh -c is there for the ${PORT:-8000} defaults, which an exec-form CMD wouldn't expand. The exec replaces the shell with uvicorn, so uvicorn receives the stop signal directly and shuts down cleanly instead of being killed after a timeout.

--workers runs several processes. Two is a sane default for a small server. Anything you keep in process memory (an in-memory rate limiter, a cache) is then per worker, which is fine for a rate limit and wrong for anything that has to be shared.

Migrations on start assume one instance starts at a time. If you scale to several instances that start together, run migrations as a separate release step instead, so two containers don't race to apply the same one.

A health check that means something

async def database_ok(session: AsyncSession) -> bool:
    try:
        await session.execute(text("SELECT 1"))
    except Exception:
        return False
    return True


@router.get("/health")
async def health(session: SessionDep):
    """For uptime monitors and container health checks."""
    ok = await database_ok(session)
    return JSONResponse(
        {"status": "ok" if ok else "error", "database": "ok" if ok else "unreachable"},
        status_code=200 if ok else 503,
    )

A health check that returns 200 whenever the process is up tells you nothing about whether the app works. Touching the database catches the most common production failure (a wrong DATABASE_URL, a database that didn't come up) and returns 503, which every load balancer and uptime monitor understands.

Docker can run it too. The slim image has no curl, so use Python:

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
    CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\", \"8000\")}/health')"

urlopen raises on a 503, Python exits with an error, and Docker marks the container unhealthy. --start-period gives the migrations time to finish before failures count.

The postgres:// URL problem

Managed databases hand you a URL like postgresql://user:pass@host:5432/db, sometimes the older postgres://, sometimes with ?sslmode=require. SQLAlchemy's async engine needs postgresql+asyncpg://, and asyncpg calls the SSL option ssl, not sslmode. Fix it once, where the setting is read, instead of asking every deployment to rewrite the URL:

class Settings(BaseSettings):
    database_url: str = "postgresql+asyncpg://app:app@db:5432/app"

    @field_validator("database_url")
    @classmethod
    def use_asyncpg_driver(cls, url: str) -> str:
        """Hosts like Railway hand out postgres:// or postgresql:// URLs; the app needs
        the asyncpg driver, and asyncpg spells sslmode as ssl."""
        for prefix in ("postgres://", "postgresql://"):
            if url.startswith(prefix):
                url = "postgresql+asyncpg://" + url.removeprefix(prefix)
        return url.replace("sslmode=", "ssl=")

Alembic reads the same setting (in migrations/env.py), so the migrations and the app always talk to the same database through the same driver.

Proxy headers: https and the real client IP

In production the app sits behind a proxy (Caddy, or the platform's load balancer) that terminates HTTPS and forwards plain HTTP. Without help, the app thinks every request is http:// from the proxy's IP. That breaks three things quietly: redirects and absolute URLs come out as http://, secure cookies can misbehave, and every visitor shares the proxy's IP, so an IP-based login rate limit blocks everyone at once.

The fix is two settings. --proxy-headers (in the CMD above) makes uvicorn read X-Forwarded-Proto and X-Forwarded-For. And FORWARDED_ALLOW_IPS says whose headers to believe. Trusting everyone (*) is only safe when nothing but your proxy can reach the app: with Docker Compose, publish no ports for the app container; on a platform, the app is only reachable through the platform's proxy.

Separately, don't build links from the request at all where it matters. Emails with verification or reset links should use a configured BASE_URL, because the Host header is whatever the client sent.

Refuse to start with unsafe settings

@model_validator(mode="after")
def check_production_secrets(self) -> "Settings":
    if self.is_production and ("change-me" in self.secret_key or len(self.secret_key) < 32):
        raise ValueError("Set SECRET_KEY to a long random string before running in production.")
    return self

A crash on start is the best possible outcome for a missing secret: the deploy fails loudly instead of running with the example key from .env.example, which signs every session cookie. The same flag can switch production behaviour on: Secure cookies, and no public /docs:

app = FastAPI(
    docs_url=None if settings.is_production else "/docs",
    redoc_url=None,
    openapi_url=None if settings.is_production else "/openapi.json",
)

Generate the key with python -c "import secrets; print(secrets.token_urlsafe(48))".

Option 1: a VPS with Docker Compose and Caddy

One small server (2 vCPU and 4 GB RAM is plenty to start) runs three containers: PostgreSQL, the app, and Caddy, which gets a Let's Encrypt certificate and renews it on its own.

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

  web:
    build: .
    restart: unless-stopped
    env_file: .env
    environment:
      # Only Caddy can reach the app, so its X-Forwarded-* headers can be trusted.
      FORWARDED_ALLOW_IPS: "*"
    expose:
      - "8000"
    depends_on:
      db:
        condition: service_healthy

  caddy:
    image: caddy:2.10-alpine
    restart: unless-stopped
    environment:
      DOMAIN: ${DOMAIN:?set DOMAIN in .env, e.g. app.example.com}
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - web

volumes:
  pgdata:
  caddy_data:
  caddy_config:

The details that matter:

The whole Caddy configuration:

# deploy/Caddyfile
{$DOMAIN} {
	encode zstd gzip
	reverse_proxy web:8000

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		X-Frame-Options "DENY"
		-Server
	}
}

Caddy sets X-Forwarded-For and X-Forwarded-Proto for the app by itself. The header block adds the usual security headers in one place, instead of a middleware in the app.

On the server: install Docker (curl -fsSL https://get.docker.com | sh), put the code in /opt/my-saas, write .env with APP_ENV=production, SECRET_KEY, BASE_URL, DOMAIN, the POSTGRES_* values and a DATABASE_URL that uses db as the host, and add COMPOSE_FILE=docker-compose.prod.yml so plain docker compose uses the production file. Then:

getent hosts app.example.com      # must print this server's address first
docker compose up -d --build
docker compose logs -f web

Check DNS before the first start. Caddy can only get a certificate once the domain points at the server, and Let's Encrypt allows only a few failed attempts per hour. A leftover AAAA record pointing somewhere else can break it too: Let's Encrypt prefers IPv6 when an AAAA record exists.

For the firewall, allow TCP 22, 80 and 443 and UDP 443, either in your provider's panel or with ufw. Be aware that ports Docker publishes bypass ufw's rules; here that's only 80 and 443, which are open anyway, but never add a ports: entry for the database.

Back up the database every night with pg_dump through the running container, and keep two weeks:

file="$BACKUP_DIR/db-$(date +%Y-%m-%d-%H%M).sql.gz"
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner' | gzip > "$file"
find "$BACKUP_DIR" -name 'db-*.sql.gz' -mtime +14 -delete

A backup on the same server doesn't survive losing the server, so copy the files somewhere else as well, and try a restore once before you need one.

Option 2: Railway

On a platform like Railway you skip the server, Caddy and certificates. Railway builds the same Dockerfile, runs a managed PostgreSQL next to it, and terminates HTTPS. One file in the repository tells it how to deploy:

{
  "$schema": "https://railway.com/railway.schema.json",
  "build": {
    "builder": "DOCKERFILE",
    "dockerfilePath": "Dockerfile"
  },
  "deploy": {
    "healthcheckPath": "/health",
    "healthcheckTimeout": 120,
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 5
  }
}

With healthcheckPath set, Railway only switches traffic to a new deployment once /health answers 200. A broken build or a failing migration leaves the old version serving. That's the payoff for making the health check touch the database.

In the app service's variables:

Railway sets PORT itself; the ${PORT:-8000} in the CMD picks it up. Every push to the connected branch deploys a new version.

After the first deploy

Checklist

This deployment setup ships with getpylaunch, a FastAPI SaaS starter I sell, with step-by-step guides for both a Hetzner VPS and Railway.