Skip to content

Deployment

Sapari v1.0 deploys to self-managed Hetzner servers via Docker Compose, with Cloudflare Pages serving the frontend and landing. See Infrastructure Architecture for the full picture.

Infrastructure Overview (v1.0)

Component Service
Backend API + 6 workers + scheduler Hetzner CCX23 (production) / CX33 (staging), Docker Compose
Frontend + Landing Cloudflare Pages, 3 projects (see Frontend deploys)
TLS + reverse proxy Caddy 2 with Cloudflare DNS-01 challenge
API proxy to backend Cloudflare Worker (makes frontend + API same-origin)
Database Neon Postgres (two projects: sapari-staging, sapari-production)
Message Broker RabbitMQ 3 (self-hosted on the same server)
Cache + Sessions + SSE Redis 7 (self-hosted on the same server)
Object Storage Cloudflare R2 — sapari-media + sapari-exports per env, plus legacy sapari-raw / sapari-assets pending decommission
Observability Logfire (traces + structured logs)

Docker Images

One image, built from the prod target of backend/Dockerfile, contains everything: API, workers, scheduler, migrations, seed scripts.

Built by GitHub Actions and pushed to GHCR on every push to staging or main:

ghcr.io/<org>/sapari-backend:<sha>         # Immutable, for rollback
ghcr.io/<org>/sapari-backend:staging        # Floating, latest staging build
ghcr.io/<org>/sapari-backend:production     # Floating, latest production build

Each image carries a sapari.alembic_head label so rollback.sh can detect migration mismatches.

Deploy via Scripts

All deployment is driven by scripts in scripts/deployment/. CD calls them; operators SSH'd into the server call the same scripts.

What Command
Standard deploy (pull, migrate, restart, health check) ./scripts/deployment/deploy.sh
Roll back to a specific SHA ./scripts/deployment/rollback.sh <sha>
Restart a service (config changed, stuck worker) ./scripts/deployment/restart.sh [service]
Run a one-off Python task ./scripts/deployment/run-task.sh <script>
Check health ./scripts/deployment/health.sh

See scripts/README.md for the full operator cheatsheet, or scripts.md for in-depth documentation of each script.

Frontend deploys (Cloudflare Pages)

Three Pages projects, all built from benavlabs/sapari:

Project Production branch Serves Ships on
sapari-frontend main app.sapari.io Deploy hook only — automatic deployments are OFF
sapari-frontend-staging staging staging.sapari.io (Access-gated) Every push to staging
sapari-landing main sapari.io Every push to main

Why production auto-deploy is off. The backend deploys manually (Deploy Production), the frontend would deploy on merge. Left on, a stagingmain merge puts a new SPA in front of users minutes before the API it calls exists — the frontend leads the backend by construction. So sapari-frontend ships only when deploy-production.yml fires its deploy hook, which happens after the backend's health check passes. One dispatch, backend first, frontend second.

Turning automatic deployments back on for sapari-frontend silently reopens that gap. It is the single setting in this system most likely to be "fixed" by someone who doesn't know why it's off.

The hook URL lives in the CF_PAGES_DEPLOY_HOOK secret on the production GitHub environment. Anyone holding it can deploy the production frontend — treat it like a credential, not a URL. To ship the frontend without a backend deploy (rare — a frontend-only hotfix on main), POST the hook by hand.

Both frontend projects use build command npm run build, output dist, root directory frontend, and build watch path frontend/* (so backend-only pushes don't rebuild the SPA). Neither needs build-time environment variables: the SPA calls the API on a relative /api/v1 path, which the Cloudflare Worker proxies same-origin to Hetzner.

First-time Server Setup

Order a fresh Hetzner box, then:

# As root:
sudo ./scripts/deployment/setup-server.sh --my-ip <YOUR_IP>

# As deploy user (after clone + .env):
./scripts/deployment/first-deploy.sh

Environment Variables

Each server has one /home/deploy/sapari/.env file (not committed). Copy from backend/.env.production.example and fill in. The production security validator blocks startup if critical values are misconfigured (weak SECRET_KEY, CREATE_TABLES_ON_STARTUP=true, etc.).

Key groups: - Required: SECRET_KEY, DATABASE_URL, STRIPE_*, POSTMARK_SERVER_TOKEN, STORAGE_*, OPENAI_API_KEY, DEEPSEEK_API_KEY, OAUTH_*, ADMIN_*, TASKIQ_RABBITMQ_USER/PASSWORD, CACHE_REDIS_PASSWORD, CLOUDFLARE_API_TOKEN - Must override from defaults: ENVIRONMENT=production|staging, CREATE_TABLES_ON_STARTUP=false, STRIPE_TEST_MODE=false (prod only), FRONTEND_URL, CORS_ORIGINS, CACHE_BACKEND=redis, TASKIQ_BROKER_TYPE=rabbitmq

Scaling

v1.0 is single-host vertical scaling -- resize the Hetzner box when resources get tight. Per-worker concurrency is controlled by TASKIQ_WORKER_CONCURRENCY in the env file (default: 1).

For horizontal scaling, see the architecture roadmap (v1.2 splits API + workers; v2.0 adds k3s + GPU).

Health Checks

GET /health          Liveness check (returns immediately)
GET /health/ready    Readiness check (DB + Redis + RabbitMQ + storage)

The admin panel's System Health page is a link landing pad pointing at Beszel (system + container metrics) and Dozzle (live container logs). The Overview tab carries a quick-glance health snippet via /admin/audit/health + /admin/audit/queues. See Monitoring for the canonical ops tools.

Key Files

Component Location
Dockerfile (single image, prod target) backend/Dockerfile
Production compose docker-compose.prod.yml
Caddy image + config caddy/Dockerfile, caddy/Caddyfile
Deployment scripts scripts/deployment/
Operator cheatsheet scripts/README.md
Production env template backend/.env.production.example
Settings backend/src/infrastructure/config/settings.py

← SSE Integration Monitoring →