Skip to content

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)

  1. Sign up at neon.com. Free tier.
  2. Create project sapari-<env>. Region must match the Hetzner datacenter — Hillsboro → us-west-2, Ashburn → us-east-1. (why)
  3. Connection string: use the direct endpoint (ep-*.c-*.<region>.aws.neon.tech), not the -pooler one. (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-mediathe unified bucket: every uploaded video and b-roll asset, plus their artifacts (audio, proxy, sprite, thumbnail), under media/<prefix>/<uuid>/<filename>
  • sapari-exports — rendered MP4s
  • sapari-raw, sapari-assetslegacy, being decommissioned. Kept alive until the canvas storage migration has run and soaked

Staging mirrors with a sapari-staging- prefix.

  1. CF Dashboard → R2 → Create bucket, or wrangler r2 bucket create <name>.
  2. 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, IntegrityError handling on MediaFile, 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.
  3. Set CORS — the browser PUTs directly to R2 via presigned URLs, so upload fails without it. ETag must be in ExposeHeaders: multipart completion requires byte-exact ETags echoed back from each part response. Never use a wildcard origin — scripts/ci/r2-posture-check.sh fails on it, correctly.
  4. 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.
  5. Verify with scripts/ci/r2-posture-check.sh (bucket private, no r2.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.

  1. Zero Trust → Access → Applications → Add application → Self-hosted.
  2. Application domain: staging.sapari.io (all paths).
  3. Policies: allow your GitHub account(s) / team.
  4. 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.

  1. Create account + add Sender Signature or verify the sapari.io domain.
  2. DKIM: add the *._domainkey.sapari.io TXT 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 is p=reject, so emails without DKIM will be rejected.
  3. 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:

  1. Install the "Get cookies.txt LOCALLY" Chrome extension (or equivalent). Avoid cloud-sync ones — they exfiltrate cookies.
  2. 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.
  3. 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.