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 staging → main 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 |