Cloudflare Cache Observability
Section titled “Cloudflare Cache Observability”Issue #91 adds a dedicated cache observability layer for NutsNews public routes.
Caching protects Supabase, the VPS primary origin, the Vercel fallback target, and the reader experience. The goal is to catch accidental cache regressions such as no-store headers, missing CDN headers, unexpected policy markers, or /api/articles falling out of the cacheable path.
What is checked
Section titled “What is checked”The cache observability checker compares expected and actual response headers for these public routes:
| Route | Expected policy | Why it matters |
|---|---|---|
/ | public-home-cache-3600s | Homepage traffic should be CDN-cacheable. |
/articles/<id> | public-article-cache-3600s | Article detail pages should not hit origin for every reader. |
/api/articles?limit=1 | public-api-cache-3600s | Highest-priority route because API regressions can increase Supabase pressure. |
/sitemap.xml | public-sitemap-cache-3600s | Search crawler route should be cached longer than reader pages. |
/robots.txt | public-robots-cache-3600s | Search crawler policy route should be cached longer than reader pages. |
/icon.png | public-static-asset-cache-immutable | Static assets should be immutable and edge-cacheable. |
/apple-icon.png | public-static-asset-cache-immutable | Static app icons should be immutable and edge-cacheable. |
The source of truth is:
web/cache-observability.config.jsonPublic anonymous reader pages and feed APIs use a conservative browser cache header and a longer CDN-specific cache header:
cache-control: public, max-age=0, must-revalidatecdn-cache-control: public, s-maxage=3600, stale-while-revalidate=86400cloudflare-cdn-cache-control: public, s-maxage=3600, stale-while-revalidate=86400vercel-cdn-cache-control: public, s-maxage=3600, stale-while-revalidate=86400The one-hour CDN cache window is configurable in the web app with:
NUTSNEWS_PUBLIC_CDN_S_MAXAGE_SECONDS=3600NUTSNEWS_PUBLIC_CDN_STALE_WHILE_REVALIDATE_SECONDS=86400Only anonymous/public content should use these headers. Admin pages, auth routes, contact form submissions, monitoring routes, preview-only/private routes, POST or mutation routes, and owner-only routes must keep no-store CDN headers.
Local commands
Section titled “Local commands”Validate the route configuration without making network requests:
cd webnpm run audit:cache:configRun a live check against production:
cd webnpm run audit:cache -- --url https://www.nutsnews.comSpot-check the most important public routes with curl:
curl -I https://nutsnews.com/curl -I "https://nutsnews.com/api/articles?limit=5"curl -I "https://nutsnews.com/articles/<article-id>"Look for cache-control: public, max-age=0, must-revalidate and at least one visible CDN cache-control header with s-maxage=3600 and stale-while-revalidate=86400. Cloudflare, VPS-primary origin handling, and Vercel fallback handling may consume targeted CDN headers before the final browser response, so the cache observability report is the stronger end-to-end check.
Run a live check against a Vercel preview URL:
cd webnpm run audit:cache -- --url https://<preview-url>If article discovery cannot find an article, pass one manually:
cd webnpm run audit:cache -- --url https://www.nutsnews.com --article-path /articles/<article-id>The report is written to:
web/reports/cache-observability/cache-observability.mdweb/reports/cache-observability/cache-observability.jsonGitHub Actions
Section titled “GitHub Actions”Workflow:
.github/workflows/cloudflare-cache-observability.ymlIt has two jobs:
| Job | When it runs | What it does |
|---|---|---|
Cache config regression check | Pull requests and pushes to main | Validates the cache route policy config and uploads a report artifact. |
Live cache policy and alert check | Scheduled and manual workflow dispatch | Checks live Cloudflare/VPS-primary response headers and fails when cache policy regresses. |
The live job runs every 6 hours by default.
A failed scheduled run is the alert. GitHub will surface the failing workflow, annotations, and the uploaded report artifact.
After the VPS-primary cutover, the repository variables should keep the live job pointed at the public production URL:
NUTSNEWS_CACHE_OBSERVABILITY_URL=https://www.nutsnews.comNUTSNEWS_PRIMARY_PRODUCTION_URL=https://www.nutsnews.com/NUTSNEWS_VPS_PRODUCTION_DIRECT_URL=https://vps.nutsnews.com/NUTSNEWS_VERCEL_SECONDARY_PRODUCTION_URLS=https://nutsnews.vercel.app/Do not point the scheduled cache observability job at the direct VPS hostname or the Vercel secondary hostname during normal operations. Those targets are useful for diagnosis, but the alert should represent the reader-visible Cloudflare production entrypoint.
Admin dashboard
Section titled “Admin dashboard”Protected admin route:
/admin/cacheThe dashboard shows:
- Overall pass/warn/fail status
- Expected vs actual
X-NutsNews-Cache-Policy Cache-Control,CDN-Cache-Control,Cloudflare-CDN-Cache-Control, andVercel-CDN-Cache-Controlcf-cache-statuswhen the checked URL is behind Cloudflare- Article page discovery status
- Per-route findings
By default the admin page checks the current request host. On a Vercel preview deployment, /admin/cache checks that preview URL. In production, it checks the production host.
Optional environment overrides:
NUTSNEWS_CACHE_OBSERVABILITY_URL=https://www.nutsnews.comNUTSNEWS_PRIMARY_PRODUCTION_URL=https://www.nutsnews.comNUTSNEWS_CACHE_ARTICLE_PATH=/articles/<article-id>Use the URL override when testing a preview deployment, a Vercel fallback drill, or when article discovery needs a known article URL. If NUTSNEWS_CACHE_OBSERVABILITY_URL is unset, the scheduled workflow falls back to NUTSNEWS_PRIMARY_PRODUCTION_URL and then https://www.nutsnews.com.
Expected headers
Section titled “Expected headers”Reader-facing public routes should include these headers:
Cache-ControlCDN-Cache-ControlCloudflare-CDN-Cache-ControlVercel-CDN-Cache-ControlX-NutsNews-Cache-Policy/api/articles should also include:
X-NutsNews-Article-Data-SourceX-NutsNews-Feed-SnapshotThese API headers show whether the request is served by the Supabase public feed snapshot, the articles fallback, or the Cloudflare edge snapshot fallback.
What fails the check
Section titled “What fails the check”The live check fails when:
- A required route returns an unexpected HTTP status.
- A required cache header is missing.
X-NutsNews-Cache-Policydoes not match the expected policy.Cache-Controlcontainsno-storeorprivateon a public route.- Required CDN cache hints such as
s-maxage=3600,stale-while-revalidate=86400, orimmutableare missing. /api/articlesreports CloudflareBYPASSorDYNAMICfor all observed samples.
Warnings are used when cf-cache-status is not present. That is normal on local checks and Vercel preview checks before Cloudflare.
Preview deployment testing
Section titled “Preview deployment testing”After Vercel creates a preview URL for the PR, run:
cd webnpm run audit:cache -- --url https://<preview-url>Look for:
Status: passor warnings only for missingcf-cache-status.- Homepage policy:
public-home-cache-3600s. - Articles API policy:
public-api-cache-3600s. - Sitemap policy:
public-sitemap-cache-3600s. - Robots policy:
public-robots-cache-3600s. - Static icon policy:
public-static-asset-cache-immutable. - No public route with
Cache-Control: no-store.
On production behind Cloudflare, also look for cf-cache-status samples. A first request can be MISS; repeated requests should eventually show HIT for cacheable routes that Cloudflare can cache. Dynamic routes may legitimately report DYNAMIC; treat the workflow report and route policy markers as the source of truth.
Production deploys do not automatically purge the Cloudflare zone. The manual operator recovery path is Manual Cloudflare Production Cache Purge:
.github/workflows/cloudflare-production-cache-purge.ymlconfirmation=purge-production-cachedry_run=falseUse it after a production release only when stale edge content or a cache/header
policy change needs immediate correction. Then rerun the curl checks and the
cache observability audit against https://www.nutsnews.com.
Post-cutover evidence from issue #398:
| Check | Result |
|---|---|
| Manual purge | ramideltoro/nutsnews run 29887566515 passed |
| Live cache observability | ramideltoro/nutsnews run 29887610632 passed |
| Previously stale sitemap shard | https://www.nutsnews.com/articles/sitemap/0.xml returned HTTP 200 after purge |
| Source/build under test | 521fcababfbaff54785c6b5cf5995dc7f9cf62a1, build 29885890520-1 |
Common fixes
Section titled “Common fixes”/api/articles is not cacheable
Section titled “/api/articles is not cacheable”Check:
web/app/api/articles/route.tsweb/lib/cacheHeaders.tsweb/middleware.tsweb/next.config.tsThe route should return ARTICLE_API_CACHE_HEADERS on successful responses.
A public page shows no-store
Section titled “A public page shows no-store”Check web/middleware.ts and make sure the route is not accidentally included in isOperationalNoStoreRoute.
Sitemap or robots has the wrong policy
Section titled “Sitemap or robots has the wrong policy”Check the long-cache rules in:
web/middleware.tsweb/next.config.tsThey should use a 3600-second CDN policy.
Static assets are missing Vercel CDN headers
Section titled “Static assets are missing Vercel CDN headers”Check the static asset entries in:
web/next.config.tsStatic assets should send Cache-Control, CDN-Cache-Control, Cloudflare-CDN-Cache-Control, and Vercel-CDN-Cache-Control with public, max-age=31536000, immutable.
Header visibility note
Section titled “Header visibility note”Cloudflare-CDN-Cache-Control and Vercel-CDN-Cache-Control are origin/CDN control headers. They may be consumed or hidden by the CDN layer and should not be required as visible browser response headers. The dashboard treats missing targeted CDN control headers as warnings when the visible cache policy marker and shared CDN/browser cache headers still show the route is cacheable.
For article detail pages, Cloudflare HIT is treated as the edge-cache source of truth. A browser-visible private or no-store header on an article page is still surfaced as a warning so it can be reviewed separately.
A visible Cache-Control header that includes s-maxage also counts as CDN cache evidence. Some platforms consume or hide targeted CDN headers, but s-maxage in the final Cache-Control response still proves the route is intentionally shared-cacheable.
