External Services¶
Sapari depends on ~10 external services for data, payments, email, DNS, storage, and CI access. This page is a per-service runbook for provisioning each one from scratch — use it for disaster recovery or setting up a new environment. For the rationale behind each choice, see architecture decisions.
Each section lists what to create and which values end up in the server's .env.
Neon Postgres¶
Two separate projects — sapari-staging and sapari-production. Not branches. (why)
- Sign up at neon.com. Free tier.
- Create project
sapari-<env>. Region must match the Hetzner datacenter — Hillsboro →us-west-2, Ashburn →us-east-1. (why) - Connection string: use the direct endpoint (
ep-*.c-*.<region>.aws.neon.tech), not the-poolerone. (why)
Capture: DATABASE_URL in async form. Two gotchas when copying from Neon's UI:
- Replace sslmode=require with ssl=require — asyncpg uses the latter (psycopg2 uses the former).
- Drop any &channel_binding=require Neon adds — asyncpg auto-negotiates SCRAM channel binding via the auth handshake; passing it as a query param TypeErrors.
Final form: postgresql+asyncpg://USER:PASS@ep-xxx.c-N.<region>.aws.neon.tech/dbname?ssl=require.
Upgrade trigger: CU-hours > 90/mo on production, or storage > 0.4 GB.
Cloudflare R2¶
Buckets per environment, post canvas-media:
sapari-media— the unified bucket: every uploaded video and b-roll asset, plus their artifacts (audio, proxy, sprite, thumbnail), undermedia/<prefix>/<uuid>/<filename>sapari-exports— rendered MP4ssapari-raw,sapari-assets— legacy, being decommissioned. Kept alive until the canvas storage migration has run and soaked
Staging mirrors with a sapari-staging- prefix.
- CF Dashboard → R2 → Create bucket, or
wrangler r2 bucket create <name>. - There is no object-versioning toggle. R2 does not offer S3-style versioning — the bucket Settings pane has no such option (verified in the dashboard 2026-07-31). Delete protection comes from application-level measures instead: soft deletes,
IntegrityErrorhandling onMediaFile, and the reconcile cron. Any runbook step that says "enable versioning" is wrong; plan destructive storage operations on the assumption that a delete is final. - Set CORS — the browser PUTs directly to R2 via presigned URLs, so upload fails without it.
ETagmust be inExposeHeaders: multipart completion requires byte-exact ETags echoed back from each part response. Never use a wildcard origin —scripts/ci/r2-posture-check.shfails on it, correctly. - Scope the API token to include every bucket. Tokens are per-environment with Object Read & Write. If a token was created against specific buckets rather than the whole account, adding a bucket requires editing the token — otherwise every write to the new bucket 403s while the old ones keep working, which reads as a code bug.
- Verify with
scripts/ci/r2-posture-check.sh(bucket private, nor2.dev, no custom domain, no wildcard CORS).
Capture: STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEY, STORAGE_ENDPOINT_URL (per-account, same for all buckets), bucket names.
The posture check does not verify existence
Every probe in r2-posture-check.sh tests for public exposure. A bucket that does not exist passes all of them — the unsigned LIST 404s, which counts as the expected 4xx, and there are no domains or CORS rules to find. Creating the bucket is not confirmed by that job going green.
Cloudflare DNS¶
One zone for sapari.io. Records:
| Type | Name | Target | Proxied |
|---|---|---|---|
| A | @ (apex) |
Cloudflare Pages IP | Yes |
| CNAME | www |
sapari.io |
Yes |
| CNAME | app |
sapari-frontend.pages.dev |
Yes |
| CNAME | staging |
sapari-frontend-staging.pages.dev |
Yes |
| A | api |
Production Hetzner IPv4 | No (grey cloud) |
| A | api-staging |
Staging Hetzner IPv4 | No (grey cloud) |
Backend A records are DNS-only (grey cloud) because Caddy runs the TLS termination and the backend firewall restricts to Cloudflare IPs. Proxying would double-terminate TLS.
Caddy needs an API token for DNS-01 ACME challenge (port 80 stays closed on the server). Create under My Profile → API Tokens → Create Token → template "Edit zone DNS". Scope: Zone:DNS:Edit on sapari.io only.
Capture: CLOUDFLARE_API_TOKEN.
Cloudflare Pages¶
Three projects, all built from benavlabs/sapari. Each Pages project has exactly one production branch, so an environment = a project — not a branch setting inside a shared project.
| Project | Root dir | Production branch | Domain | Ships on |
|---|---|---|---|---|
sapari-frontend |
frontend |
main |
app.sapari.io |
deploy hook only (automatic deployments OFF) |
sapari-frontend-staging |
frontend |
staging |
staging.sapari.io |
push to staging |
sapari-landing |
landing |
main |
sapari.io |
push to main |
All three: build command npm run build, output dist/. Both frontend projects set build watch paths to frontend/* and preview branches to None. Production's deploy hook is fired by deploy-production.yml after the backend health check — see Frontend deploys for why it isn't automatic.
Clickjacking headers (X-Frame-Options, CSP frame-ancestors) ship via frontend/public/_headers and landing/public/_headers, which Pages applies at the build-output root. They travel with the build, so a project deploying a branch that lacks the file serves no framing header at all.
Cloudflare Workers¶
One Worker per environment (sapari-proxy-staging, sapari-proxy-production), attached by path-scoped route patterns. Handles two path prefixes — /api/* proxies to the Hetzner backend, /media/v1/<jwt> verifies a media JWT and streams bytes from R2. Every other path goes straight from the custom domain to the Pages project and never reaches the Worker; anything that does reach it outside those prefixes gets a 404. (why)
Source lives in worker/. Route patterns (the two prefixes above) are dashboard-managed, not in wrangler.toml — adding a new prefix requires both a code change and a dashboard route addition.
See cloudflare-workers.md for the full deploy runbook, secret management, route-pattern gotcha, and troubleshooting matrix. Follow that doc when provisioning a new env or cutting over production.
Sessions + SSE (keep-alive: true) flow through the /api/* path unchanged.
Cloudflare Access¶
Gates staging behind GitHub OAuth so the world can't poke at staging.sapari.io.
- Zero Trust → Access → Applications → Add application → Self-hosted.
- Application domain:
staging.sapari.io(all paths). - Policies: allow your GitHub account(s) / team.
- Identity provider: GitHub OAuth (one-time config in Zero Trust → Settings → Authentication).
Production is public — no Access rule.
Stripe¶
One Stripe account, two modes — test (staging) and live (production).
Test mode setup:
1. Create products + prices matching scripts/create_first_tier.py expectations.
2. Webhook endpoint → https://staging.sapari.io/api/v1/webhooks/stripe (routed via Cloudflare Worker). Events: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, invoice.payment_failed, payment_intent.succeeded.
3. Save signing secret.
Live mode setup: same steps, webhook points at https://app.sapari.io/api/v1/webhooks/stripe, separate signing secret.
Capture: STRIPE_API_KEY, STRIPE_WEBHOOK_SECRET (distinct per env).
The backend treats STRIPE_TEST_MODE=true as test and requires false for production.
Postmark¶
Transactional email. One account, two message streams — outbound-staging and outbound-production.
- Create account + add Sender Signature or verify the
sapari.iodomain. - DKIM: add the
*._domainkey.sapari.ioTXT record Postmark gives you to Cloudflare DNS. Start this early — DNS propagation can take hours and Postmark's DNS check must pass before DKIM signing turns on. Sapari's DMARC isp=reject, so emails without DKIM will be rejected. - Create two Server tokens (staging + production).
Capture: POSTMARK_SERVER_TOKEN (per env), EMAIL_FROM_ADDRESS (e.g., noreply@sapari.io).
OAuth providers (user login)¶
Separate from Cloudflare Access. These let Sapari's end users log in with Google / GitHub.
Google OAuth:
1. Google Cloud Console → OAuth consent screen → External, scopes: email, profile.
2. Create OAuth Client ID (Web application). Authorized redirect URIs:
- https://staging.sapari.io/api/v1/auth/oauth/google/callback
- https://app.sapari.io/api/v1/auth/oauth/google/callback
GitHub OAuth: 1. GitHub → Settings → Developer settings → OAuth Apps → New. 2. One per environment. Callback URL is the same shape as Google.
Capture: OAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET, OAUTH_GITHUB_CLIENT_ID, OAUTH_GITHUB_CLIENT_SECRET. One set per environment.
YouTube cookies (optional)¶
Only needed when the download worker logs show yt-dlp extractor errors like "Sign in to confirm you're not a bot". YouTube increasingly blocks extraction from datacenter IPs; passing cookies from a logged-in browser session bypasses the check. Most videos extract fine without this.
Generate cookies:
- Install the "Get cookies.txt LOCALLY" Chrome extension (or equivalent). Avoid cloud-sync ones — they exfiltrate cookies.
- In a Chrome profile logged in to a throwaway YouTube account (not your personal one — these credentials will live on the server), visit
https://www.youtube.com. - Open the extension → Export → select Netscape format → save as
youtube-cookies.txt.
Install on the server:
# From your laptop:
scp youtube-cookies.txt deploy@<server-tailnet-ip>:~/sapari/secrets/
ssh deploy@<server-tailnet-ip> 'chmod 600 ~/sapari/secrets/youtube-cookies.txt'
# Set the env var in ~/sapari/.env:
YOUTUBE_COOKIES_FILE=/run/secrets/youtube-cookies.txt
# Restart the download worker to pick up the new env + mount:
docker compose -f ~/sapari/docker-compose.prod.yml --env-file ~/sapari/.env up -d taskiq-download-worker
The download worker mounts ./secrets:/run/secrets (bind, read-write so yt-dlp can refresh tokens). Files in secrets/ are gitignored except the README.
Refresh cadence: YouTube session cookies expire; re-export every few months or whenever extraction starts failing again. The file is hot-reloaded by yt-dlp — no deploy needed after replacing it, just restart the worker.
GHCR (GitHub Container Registry)¶
Backend images push to ghcr.io/benavlabs/sapari-backend. No provisioning needed — any repo with GitHub Actions has ghcr.io/<org>/* access via the built-in GITHUB_TOKEN.
The server pulls public images, so no registry auth required on the server either.
Tailscale (CI access to servers)¶
CI workflows join the tailnet to reach tailnet-only SSH. (why)
One-time tailnet ACL (in Tailscale admin console → Access Controls):
{
"tagOwners": {
"tag:server": ["<tailnet-owner>"],
"tag:ci": ["<tailnet-owner>"]
},
"grants": [
{ "src": ["*"], "dst": ["*"], "ip": ["*"] }
]
}
(Allow-all today; tighten to tag:ci → tag:server:22 later if needed.)
Per server: in the admin console → Machines → pick the device → Edit ACL tags → add tag:server.
OAuth client for CI (→ Settings → OAuth clients → Generate):
- Scopes: Keys → Auth Keys → Write
- Tags: tag:ci
Capture: TS_OAUTH_CLIENT_ID, TS_OAUTH_SECRET. These go in both the staging and production GitHub environments (reused across envs).
GitHub repo settings¶
Environments (Settings → Environments):
- Create staging and production.
- Deployment branch policy for both: allow main (workflow_run always runs in default-branch context regardless of triggering branch).
- Secrets per environment: SSH_HOST (server's tailnet IP), SSH_KEY (dedicated CI SSH private key), TS_OAUTH_CLIENT_ID, TS_OAUTH_SECRET.
Summary — what you capture, where it goes¶
Every value above ends up in either:
1. /home/deploy/sapari/.env on the server (most secrets — see backend/.env.production.example for the full list)
2. GitHub environment secrets (only SSH_HOST, SSH_KEY, TS_OAUTH_CLIENT_ID, TS_OAUTH_SECRET)
3. 1Password (or equivalent) — back up everything so you can rebuild if the server is lost
Nothing else needs persisting outside those three places.