DEV Community

patrykkomow12
patrykkomow12

Posted on Originally published at getpylaunch.com

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
Enter fullscreen mode Exit fullscreen mode

What each part is for:

  • PYTHONUNBUFFERED=1 sends logs straight to the container output. Without it, Python buffers them and a crash can take its last log lines with it.
  • Dependencies before code. With only pyproject.toml copied, pip install . installs your dependencies into a layer that stays cached until the dependencies change. The empty app package and the uninstall are there because pip install . wants a package to install; we only want what it pulls in. Without this, every one-line change reinstalls everything.
  • A non-root user. If someone finds a way to run code through your app, they get an unprivileged user in a container, not root.

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"]
Enter fullscreen mode Exit fullscreen mode

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,
    )
Enter fullscreen mode Exit fullscreen mode

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')"
Enter fullscreen mode Exit fullscreen mode

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=")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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",
)
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

The details that matter:

  • expose, not ports, for the app and nothing at all for the database. Only Caddy is reachable from outside; that's what makes FORWARDED_ALLOW_IPS: "*" safe.
  • ${VAR:?message} makes Compose stop with that message when a variable is missing, instead of starting PostgreSQL with an empty password.
  • $$ in the health check escapes the dollar sign, so the variable is expanded inside the container, not by Compose.
  • caddy_data keeps the certificates. Lose that volume and Caddy asks Let's Encrypt again on every restart, and Let's Encrypt has rate limits.
  • UDP 443 is HTTP/3, which Caddy serves out of the box.

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  • DATABASE_URL: a reference to the PostgreSQL service's own DATABASE_URL (the variables editor offers it as a reference, so the value follows the database if it changes). It arrives as postgresql://; the validator above handles that.
  • FORWARDED_ALLOW_IPS=*: the app is only reachable through Railway's proxy.
  • APP_ENV=production, SECRET_KEY, and BASE_URL set to your public domain.

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

  • Open /health and check it says "database": "ok".
  • Sign up, and check the verification email's link points at your real domain over https (it comes from BASE_URL).
  • Point a free uptime monitor at /health; it answers 503 when the database is down.
  • If you take payments, add the webhook endpoint for the production domain and check that deliveries return 200 (a 400 usually means the signing secret belongs to another endpoint).
  • Within minutes, bots will start asking for /.env, /wp-admin and /graphql. They get 404s; that's normal.

Checklist

  • Slim image, dependencies in their own layer, non-root user, .dockerignore with .env.
  • alembic upgrade head && exec uvicorn ... with --proxy-headers and $PORT.
  • /health that queries the database and returns 503 when it can't.
  • One validator turning postgres:// into postgresql+asyncpg:// and sslmode into ssl.
  • FORWARDED_ALLOW_IPS trusted only when nothing but the proxy can reach the app.
  • Refuse to start in production without a real SECRET_KEY; no public API docs.
  • VPS: Caddy with persistent certificates, DNS checked first, nightly backups copied off the server.

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

Top comments (0)