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.
Contents
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:
PYTHONUNBUFFERED=1sends 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.tomlcopied,pip install .installs your dependencies into a layer that stays cached until the dependencies change. The emptyapppackage and the uninstall are there becausepip 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"]
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:
expose, notports, for the app and nothing at all for the database. Only Caddy is reachable from outside; that's what makesFORWARDED_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_datakeeps 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
}
}
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:
DATABASE_URL: a reference to the PostgreSQL service's ownDATABASE_URL(the variables editor offers it as a reference, so the value follows the database if it changes). It arrives aspostgresql://; the validator above handles that.FORWARDED_ALLOW_IPS=*: the app is only reachable through Railway's proxy.APP_ENV=production,SECRET_KEY, andBASE_URLset 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
/healthand check it says"database": "ok". - Sign up, and check the verification email's link points at your real domain over
https(it comes fromBASE_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-adminand/graphql. They get 404s; that's normal.
Checklist
- Slim image, dependencies in their own layer, non-root user,
.dockerignorewith.env. alembic upgrade head && exec uvicorn ...with--proxy-headersand$PORT./healththat queries the database and returns 503 when it can't.- One validator turning
postgres://intopostgresql+asyncpg://andsslmodeintossl. FORWARDED_ALLOW_IPStrusted 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.