13 KiB
Asset CDN Operations Runbook
When A7/B2/#51 is reactivated, front the boilerplate's static assets
(/assets/*, /fonts/*, /images/*) with a CDN so the origin no longer pays
the bandwidth + TLS handshake cost for every visitor and so users far from the
Frankfurt DC see sub-second first paints.
Default provider: Bunny.net (EU-based, cheap, simple). Documented alternative: Cloudflare.
Related task: docs/improvement-plan/03-scaling-infrastructure/asset-cdn.md.
Rollout status: CDN provisioning and ASSET_CDN_URL flips remain parked
with A7/B2/#51. The deploy workflow already includes a no-op-safe Bunny purge
step for future activation; keep this runbook as the reactivation guide until
docs/improvement-plan/OPERATOR-CHECKLIST.md and
docs/improvement-plan/PROGRESS.md move #51 out of backlog.
Architecture
Browser
│
├─→ cdn.{domain}/assets/app-3f4a1b.js ──→ Bunny edge (cache hit: ~1 ms)
│ │
│ └─ origin pull on miss:
│ app.{domain}/assets/app-3f4a1b.js
│
└─→ app.{domain}/ (HTML, API, /healthz) ──→ origin server (no CDN detour)
Only static directories travel through the CDN. HTML, API responses, and
/healthz//readyz go direct to origin so authenticated content and
liveness probes are never edge-cached.
Cache strategy
| Path pattern | Cache-Control |
Why |
|---|---|---|
/assets/* (Vite-hashed) |
public, max-age=31536000, immutable |
Filenames embed a content hash — safe to cache forever. |
/fonts/*, /images/* |
public, max-age=86400 |
Static brand assets without hash; 1-day TTL is the longest safe value. |
| HTML documents (loader/SSR) | no-store (unless loader overrides) |
May contain CSRF tokens, session-derived markup, or feature flags. |
/api/*, /healthz, /readyz |
bypass cache (edge rule) | Authenticated and/or liveness-critical — caching corrupts behaviour. |
The headers above are set by server/server.ts (getStaticHeaders) for the
static directories and by apps/web/app/entry.server.tsx for the SSR HTML
response. Loaders may set their own Cache-Control to opt out of no-store
(e.g. marketing landing pages choosing public, max-age=300).
Provider comparison
| Concern | Bunny.net (default) | Cloudflare (alternative) |
|---|---|---|
| HQ | Slovenia (EU) | United States |
| Pricing baseline | ~€1-5/month for small SaaS | Free tier sufficient for small sites |
| DSGVO posture | EU jurisdiction, simple DPA | Requires Schrems-II analysis for EU users |
| DNS migration | Not required — single CNAME suffices | Free tier requires full DNS migration |
| Image optimisation | Bunny Optimizer add-on (~$9.50/mo) | Cloudflare Images ($5/mo + per-image fee) |
| Cache purge API | Simple POST with AccessKey header |
Token-scoped API; per-zone purge endpoints |
| Origin-shield option | Yes (single optional PoP in front of origin) | Yes (Argo Tunnel / Origin Rules) |
Choose Bunny.net unless the operator already runs all DNS through Cloudflare and wants a single console — then Cloudflare is the lower-friction choice.
Bunny.net setup (default, when A7/B2/#51 is reactivated)
Do not execute these steps while A7/B2/#51 remains parked. They are the reactivation checklist for the future CDN rollout. None of this is automated by the deploy pipeline.
-
Create the Bunny account. Sign up at https://bunny.net, add a payment method, generate an account-level API key in Account → API. Store it as the
BUNNY_API_KEYsecret in the Gitea / Coolify environment. -
Create a pull zone per environment.
- Staging pull zone:
- Name:
cdn-staging-{project} - Origin URL:
https://staging.{domain}(the app origin) - Pricing tier: Standard Network (high volume HDD/Volume can be selected later)
- Name:
- Production pull zone:
- Name:
cdn-{project} - Origin URL:
https://app.{domain}Note the numeric pull-zone ID from the URL — store it asBUNNY_PULL_ZONE_ID(one per environment).
- Name:
- Staging pull zone:
-
Add a custom hostname. In the pull zone, Hostnames → Add Custom Hostname:
- Staging:
cdn-staging.{domain} - Production:
cdn.{domain}Create the matching DNS records at your DNS provider:
cdn-staging.{domain} CNAME cdn-staging-{project}.b-cdn.net cdn.{domain} CNAME cdn-{project}.b-cdn.netWait until the records resolve (
dig cdn-staging.{domain}), then enable Force SSL — Bunny provisions a Let's Encrypt certificate automatically (~5 minutes). - Staging:
-
Configure edge rules.
- Bypass cache for
/api/*(matching: URL path starts with/api/). - Bypass cache for
/healthz,/readyz(URL exact match). - Force cache for
/assets/*(URL path starts with/assets/,Override Cache Time = 1 year) — defensive, since the origin already emitsimmutable, but protects against future regressions.
- Bypass cache for
-
Enable origin shield (recommended). Configuration → Origin Shield → Enabled with the closest PoP to the origin DC (Frankfurt for Hetzner FRA). Reduces origin load when the edge cold-misses.
-
Set the runtime env vars in Coolify.
Env var Where to set Example ASSET_CDN_URLWeb app (build-time + runtime) https://cdn-staging.{domain}BUNNY_PULL_ZONE_IDGitea secret + Coolify env (deploy) <pull-zone-id>BUNNY_API_KEYGitea secret + Coolify env (deploy) <bunny-api-key>ASSET_CDN_URLmust be present at build time — Vite bakes the CDN origin into asset URLs viaapps/web/vite.config.ts(resolveAssetBase). -
Trigger a deploy and run the smoke tests below.
Smoke tests (when A7/B2/#51 is reactivated)
Do not run these as active acceptance checks while CDN rollout remains parked.
# 1. Asset is served via Bunny
curl -sI https://cdn-staging.{domain}/assets/<hash>.js | head -10
# Expect: HTTP/2 200
# server: BunnyCDN-DE1 (or whichever PoP)
# cache-control: public, max-age=31536000, immutable
# x-cache: HIT (after the first request warms it)
# 2. HTML is NOT cached by Bunny
curl -sI https://staging.{domain}/ | head -10
# Expect: cache-control: no-store
# no `server: BunnyCDN` header
# 3. /fonts and /images get the 1-day TTL
curl -sI https://cdn-staging.{domain}/fonts/inter.woff2 | grep cache-control
# Expect: cache-control: public, max-age=86400
# 4. Purge API is reachable
curl -sX POST "https://api.bunny.net/pullzone/${BUNNY_PULL_ZONE_ID}/purgeCache" \
-H "AccessKey: ${BUNNY_API_KEY}" -o /dev/null -w "%{http_code}\n"
# Expect: 204
Cache-purge script
scripts/cdn/bunny-purge.ts wraps the Bunny purge API and is wired into
the Deploy workflow as the Purge asset CDN step (post-deploy). It
runs unconditionally — if BUNNY_API_KEY or BUNNY_PULL_ZONE_ID is unset
it logs and exits 0, so the step is safe before the operator finishes
the setup above.
# Manual whole-zone purge
BUNNY_PULL_ZONE_ID=<pull-zone-id> BUNNY_API_KEY=<bunny-api-key> \
pnpm exec tsx scripts/cdn/bunny-purge.ts
# Targeted purge (single asset)
BUNNY_PULL_ZONE_ID=<pull-zone-id> BUNNY_API_KEY=<bunny-api-key> \
ASSET_CDN_URL=https://cdn.{domain} \
pnpm exec tsx scripts/cdn/bunny-purge.ts --paths /assets/app-3f4a1b.js
# Multiple paths (comma-separated or repeated --paths)
pnpm exec tsx scripts/cdn/bunny-purge.ts --paths /assets/a.js,/assets/b.js
# Dry-run (prints the plan, makes no API call)
pnpm exec tsx scripts/cdn/bunny-purge.ts --dry-run
Exit codes:
| Code | Meaning |
|---|---|
0 |
Purge succeeded, or skipped because credentials were intentionally absent. |
1 |
Bunny returned a non-2xx response, or invalid CLI arguments. |
Cloudflare setup (alternative)
Only follow this section if you decided against Bunny.net in the table above.
- Add the apex/subdomain to Cloudflare (free plan suffices for small sites).
Either migrate DNS fully or proxy only the
cdn.{domain}CNAME. - Create a Page Rule for
cdn.{domain}/assets/*with: Cache Level → Cache Everything, Edge Cache TTL → 1 month. - Create a Page Rule for
cdn.{domain}/api/*with: Cache Level → Bypass. - Generate an API token (
Zone → Cache Purge) — store asCF_API_TOKEN. You'll need a sibling purge script (out of scope for this runbook; the Bunny script is the only one wired into the workflow today). - Set
ASSET_CDN_URL=https://cdn.{domain}in Coolify.
Cloudflare's default cache TTLs and "Smart Routing" can override the
Cache-Control headers the origin sends. Pin explicit Cache Rules on
the zone if behaviour diverges from the table above.
Image optimisation (opt-in, Phase 2)
We deliberately do NOT enable image optimisation on day one — it adds cost and operational surface area without a measured benefit on a small footprint.
Enable Bunny Optimizer when any of these trigger:
- Outbound image bandwidth from the CDN exceeds ~10 GB/month, OR
- User uploads (avatars, tenant logos, post images) cross ~1 000/month, OR
- Lighthouse's Largest Contentful Paint sits above 2.5 s and the audit attributes the largest portion to images.
Steps to enable:
- In the Bunny pull zone, Optimizer → Enable (~$9.50/month).
- Tag image-heavy URLs with the size hint, e.g.
https://cdn.{domain}/uploads/avatar.png?width=200&format=webp. - Adjust the web app's image components to emit the
?width=&format=query string and a<picture>block with WebP/AVIF fallbacks. - Re-run Lighthouse against the staging URL and confirm LCP improvement.
Rollback
| Failure mode | Action |
|---|---|
| CDN edge serves stale/corrupt assets | Run pnpm exec tsx scripts/cdn/bunny-purge.ts (whole-zone purge). |
| CDN provider outage / TLS failure | Unset ASSET_CDN_URL in Coolify and redeploy — Vite emits origin-relative paths next build. |
Misconfigured Cache-Control shipped (e.g. immutable on /fonts/) |
Push a fix, deploy, then whole-zone purge. |
| Need to fully cut over to the alternative provider | Provision the new pull zone, switch ASSET_CDN_URL + BUNNY_* secrets, redeploy. Old cdn.{domain} CNAME can stay until the new one stabilises. |
No dedicated feature flag is needed — ASSET_CDN_URL is the flag. Empty
or unset → no CDN detour, anywhere.
Security notes
- CDN-as-MITM: Bunny / Cloudflare see every asset request including
Referrer headers. Static brand assets are fine; never route
/uploads/<user-content>through a public-cache zone — those paths inherit theno-storedefault and stay on the origin. - CORS: When fonts/images load via
fetch(e.g. preload-on-demand), the CDN must forwardAccess-Control-Allow-Origin: https://app.{domain}from the origin. Bunny forwards CORS headers transparently; verify in the smoke tests if you add cross-origin font loading. - CSP: The Content-Security-Policy in
server/server.tskeeps abase-uri 'self'; frame-ancestors 'self'policy. When you switch on the CDN, extendscript-src,style-src,font-src, andimg-srcto include the CDN hostname (separately tracked). - Origin shield + IP allowlist: Bunny's Origin Shield gives a single outgoing IP range. Optionally whitelist those IPs at the origin firewall so attackers cannot bypass the CDN by hitting the origin directly.
- Cache poisoning: Only assets that emit
public, max-age=…go to the edge. Loader-rendered HTML defaults tono-store; loaders that need caching must opt in explicitly and set the rightVaryheaders.