DEV Community

DevKit
DevKit

Posted on

Deploying FastAPI + PostgreSQL to a single VPS with Docker Compose: HTTPS, backups and rollbacks without Kubernetes

Written with AI assistance; the template itself was built and tested by the author's AI assistant.

If you have one VPS and a FastAPI app with PostgreSQL, you don't need Kubernetes. You do need a few boring things done properly: a sane network layout, HTTPS that renews itself, backups you have actually checked, and a deploy that can undo itself. Below is how I wired each piece with plain Docker Compose. All snippets are shortened from a working template, and you can copy the ideas without buying anything.

1. Network layout: only nginx is public

Five services: app (gunicorn + uvicorn workers), db (Postgres 16), nginx, certbot, and backup. Only nginx publishes ports. The database sits in an internal network, so it has no route out and no published port.

services:
  db:
    image: postgres:16-alpine
    # No ports: on purpose.
    networks: [backend]
  app:
    networks: [frontend, backend]
  nginx:
    image: nginx:1.27-alpine
    ports:
      - "0.0.0.0:80:80"
      - "0.0.0.0:443:443"
    networks: [frontend]

networks:
  frontend:
  backend:
    internal: true
Enter fullscreen mode Exit fullscreen mode

Why so strict? Docker writes its own iptables rules, so a published port like 5432:5432 is reachable from the internet even if ufw says deny. The safest approach is to never publish what you don't want public. The app container also runs with read_only: true, cap_drop: [ALL], no-new-privileges, and a non-root user in the Dockerfile.

2. A healthcheck that actually checks the database

A /health that always returns 200 only tells you the process is alive. Here it runs SELECT 1 and returns 503 if the DB is unreachable:

@app.get("/health")
def health(db: DbSession, response: Response):
    try:
        db.execute(text("SELECT 1"))
    except SQLAlchemyError:
        response.status_code = 503
        return {"status": "degraded", "database": "down"}
    return {"status": "ok", "database": "up"}
Enter fullscreen mode Exit fullscreen mode

Compose uses it for the container healthcheck, and nginx waits for the app to be healthy before starting:

services:
  app:
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=4)"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 20s
    depends_on:
      db:
        condition: service_healthy
Enter fullscreen mode Exit fullscreen mode

urlopen raises on HTTP errors, so a 503 makes the container unhealthy. Using Python's stdlib means no curl in the image. The deploy script (section 6) also hits /health through nginx, so one request exercises nginx, app and DB.

3. HTTPS: the chicken-and-egg problem

nginx refuses to start if the certificate files referenced in its config don't exist. Let's Encrypt's webroot challenge needs a running nginx to answer on port 80. The way out:

  1. Create a throwaway self-signed certificate at the path nginx expects.
  2. Start nginx.
  3. Delete the dummy certificate.
  4. Ask certbot for the real one via webroot.
  5. Reload nginx.

The core of the bootstrap script:

certbot_run() { docker compose run --rm --no-deps "$@"; }

certbot_run --entrypoint "openssl req -x509 -nodes -newkey rsa:2048 -days 1 \
    -keyout /etc/letsencrypt/live/$domain/privkey.pem \
    -out /etc/letsencrypt/live/$domain/fullchain.pem -subj /CN=localhost" certbot

docker compose up -d nginx
# ... remove the dummy cert, then:
args=(certonly --webroot -w /var/www/certbot --email "$email" -d "$domain"
    --rsa-key-size 4096 --agree-tos --no-eff-email --non-interactive)
[[ $staging -eq 1 ]] && args+=(--staging)
certbot_run --entrypoint certbot certbot "${args[@]}"
docker compose exec -T nginx nginx -s reload
Enter fullscreen mode Exit fullscreen mode

Run it with --staging first. Staging has far looser rate limits than production, so you can debug DNS and firewall issues freely; the browser won't trust the result, which is fine. Then rerun with --force to replace it with a production certificate.

Renewal is a second container that loops twice a day:

services:
  certbot:
    image: certbot/certbot:latest
    entrypoint: >
      /bin/sh -c 'trap exit TERM;
      while :; do
      certbot renew --webroot -w /var/www/certbot --quiet;
      sleep 12h & wait $${!};
      done'
Enter fullscreen mode Exit fullscreen mode

nginx serves /.well-known/acme-challenge/ from the shared webroot volume. The template also mounts a small script into nginx that runs nginx -s reload every 6 hours, so renewed certificates get picked up without a manual step.

4. nginx: headers and rate limiting

The config is an nginx template; with NGINX_ENVSUBST_FILTER: ^DOMAIN$$ only $DOMAIN is substituted, so nginx's own $variables stay intact. The relevant parts:

server_tokens off;
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_req_status 429;

server {
    listen 443 ssl;
    http2 on;
    ssl_protocols TLSv1.2 TLSv1.3;
    client_max_body_size 5m;

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

    location / {
        limit_req zone=perip burst=20 nodelay;
        proxy_pass http://app_backend;
    }
}
Enter fullscreen mode Exit fullscreen mode

The real file also sets a CSP (default-src 'none', which suits a JSON API but not a site serving HTML), a Permissions-Policy, gzip, proxy headers and timeouts. Ten requests per second per IP with a burst of 20 is a starting point, not a tuned value; adjust it to your traffic. Because the app trusts X-Forwarded-* from anyone (FORWARDED_ALLOW_IPS: "*"), it's important that the app port is never published.

5. Backups: dump, verify, then rotate

The backup container runs a loop that sleeps until BACKUP_AT (UTC), then dumps. The important detail is that a file isn't published until it's been verified:

do_backup() {
    file="$BACKUP_DIR/${PGDATABASE}_${ts}.dump"

    pg_dump --format=custom --no-owner --file="$file.tmp"
    # A dump without a readable table of contents is useless
    pg_restore --list "$file.tmp" >/dev/null
    mv "$file.tmp" "$file"

    find "$BACKUP_DIR" -maxdepth 1 -name "${PGDATABASE}_*.dump" -type f \
        -mmin "+$((RETENTION_DAYS * 24 * 60))" -print -delete
}
Enter fullscreen mode Exit fullscreen mode

pg_restore --list is a cheap sanity check: it proves the archive is readable, but it is not a restore test. Do a real restore into a scratch database now and then. Retention defaults to 7 days. backup.sh once takes a dump immediately, which the deploy script uses.

6. Tag-based deploy with rollback

CI builds an image on a v*.*.* tag, pushes it to GHCR, then the server runs scripts/deploy.sh <tag>. The script's order is the useful part:

  1. Take a backup (abort the deploy if it fails).
  2. Remember the previous tag (APP_PREV_TAG in .env), set the new APP_TAG.
  3. docker compose pull app.
  4. Run alembic upgrade head. If it fails, restore the tag and stop.
  5. docker compose up -d, then poll /health through local nginx for up to about a minute.
  6. If it never goes healthy, print the app logs and roll back.
log "checking /health"
if healthy 30; then
    log "success: $TAG is running"
    exit 0
fi

log "/health not responding after deploying $TAG"
docker compose logs --tail=50 app || true
rollback "$PREV_TAG" || true
exit 1
Enter fullscreen mode Exit fullscreen mode

and the rollback itself is just "put the old tag back":

rollback() {
    log "rolling back to $1"
    env_set APP_TAG "$1"
    docker compose up -d --no-build app
    healthy 30 && log "rollback done" || log "WARNING: still failing"
}
Enter fullscreen mode Exit fullscreen mode

Image tags in .env make this simple: the compose file uses ${APP_IMAGE}:${APP_TAG}, so a rollback is a one-line change plus up -d.

The catch, which the script documents: rollback restores the image, not the database schema. Migrations must be backward compatible (add a column in one release, remove it in a later one). If data is damaged, you restore from the pre-deploy dump.

What this is not

  • Not high availability. One server is a single point of failure. A reboot, a disk failure or a provider outage means downtime.
  • Backups live on the same server (./backups) unless you copy them off-host yourself. A dead disk takes the database and its dumps together. Add rsync or rclone to object storage.
  • Not zero-downtime. The app container is recreated on deploy, so expect a short gap.
  • Not tested on every cloud provider. The bootstrap script targets Ubuntu 24.04 and checks for it.
  • No monitoring or alerting is built into the stack beyond container healthchecks and logs (capped at 3 × 10 MB per service).

If you want the whole thing

The snippets above are enough to rebuild the approach yourself. If you'd rather start from a finished setup, the full template (compose, nginx, certbot, backup, CI/CD workflows, bootstrap and deploy scripts, tests, Makefile, README in English and Russian) is sold for 11 USDT via the Telegram shop. There's also a free monitoring kit for a single VPS.

Top comments (0)