Skip to content

PRD 23 — Cloudflare Strategy

Last updated: 2026-04-13 Status: Active — reflects implementation state as of 2026-04-13

This document consolidates the complete Cloudflare strategy for the Savoy platform:

  • Edge caching architecture and TTLs
  • Cache invalidation (CMS-native purge system)
  • Rate limiting (plan-dependent)
  • Plan strategy per environment
  • Security perimeter (WAF, rate limits, bot protection)
  • Cost matrix

For implementation details, see:

  • docs/dev-backend-guides/04_WEBHOOKS_AND_CACHE_PURGE.md — CMS-side cache purge (CachePurgeService, scenarios S1-S7)
  • docs/dev-backend-guides/10_CLOUDFLARE_CACHE_RULES.md — Cache rules + rate limit rules + setup scripts
  • docs/PRD/02_Infrastructure_and_Environments.md § 3.4 — Plan matrix context
  • docs/PRD/09_Cache_and_Performance.md — Multi-layer cache architecture
  • docs/PRD/15_Security_and_Data_Protection.md § 2 — Security perimeter details
LayerTechnologyTTL StrategyInvalidation
BrowserCache-Control: max-age=0Revalidates every requestN/A (server controls via s-maxage)
Cloudflare Edgepublic, s-maxage=31536000, swr=601 year max, serves stale during revalidateExplicit purge via CachePurgeService
Next.js use cacheIn-memory (short-lived)5-15 min for nav tree, dictionaryProcess restart
Umbraco Content CacheIn-memoryInvalidated on publishBuilt-in
SQL ContentUmbraco source of truth

(Match what’s in apps/web/next.config.ts headers())

RouteCache-ControlCDN-Cache-ControlEdge behavior
/ + localized routes (pt/en/es/fr/de/…) via negative lookaheadpublic, max-age=0, s-maxage=31536000, stale-while-revalidate=60public, max-age=31536000Cached 1 year, purged on content publish
/_next/static/*public, max-age=31536000, immutablesameCached forever (hashed filenames)
/_next/imagepublic, max-age=2592000, stale-while-revalidate=86400public, max-age=259200030 days
/media/* (Umbraco proxy)public, max-age=2592000, stale-while-revalidate=86400public, max-age=3153600030 days browser, 1 year edge
/api/*private, no-store, no-cache, must-revalidateno-storeBYPASS
/gate/*private, no-store, no-cache, must-revalidateno-storeBYPASS

Locale matcher is /:path((?!api/|gate/|_next/|media/|favicon\.ico|robots\.txt|sitemap).*) — locale-agnostic so new languages work automatically.

EnvironmentZonePlanMonthly costStatus
DEVwycreative.com (shared)Free€0 (shared)Active
STAGEwycreative.com (shared)Free€0 (shared)Pending
QAsavoysignature.comPro~€22Pending provisioning
PRODsavoysignature.com + hotelnext.ptPro~€44 (2 zones)Pending provisioning
FeatureFreeProSavoy usage
Cache RulesLimitedFull6 rules (DEV already configured)
Rate Limit Rules1 rule, 10s period only10 rules, 10/60/600/3600s periods1 combined (DEV), 3 granular (QA/PROD)
Mitigation timeout10s minimumUp to 1hGate login brute-force needs 5min (Pro-only)
Managed RulesetStrict (limited)Strict (full)Active on all envs
Bot ManagementBasicAdvancedActive
Transform Rules1050
Page Rules325
Image Optimization (Polish, WebP, Mirage)Planned for PROD
Tiered CachingPlanned for PROD

3.3 Pro Plan Provisioning Checklist (before QA go-live)

Section titled “3.3 Pro Plan Provisioning Checklist (before QA go-live)”
  • Client approves monthly cost (~€44/mo for QA + PROD zones)
  • Upgrade wycreative.com zone OR move QA/PROD to savoysignature.com/hotelnext.pt zones (confirm DNS strategy)
  • Run CF_PLAN=pro bash scripts/cloudflare/setup-rate-limits.sh to deploy 3 granular rules
  • Run bash scripts/cloudflare/setup-cache-rules.sh (if cache rules differ per plan)
  • Enable Image Optimization (Polish, Mirage) in Speed settings
  • Enable Tiered Caching (Argo)
  • Verify WAF Managed Ruleset (Strict) active
  • Update docs/PRD/11 § 3.1 status to “Active”

See full rule definitions in docs/dev-backend-guides/10_CLOUDFLARE_CACHE_RULES.md § Cache Rules. Summary:

#MatchActionNotes
1/api/* on Savoy hostsBypass cacheDynamic responses
2/gate/* on Savoy hostsBypass cacheAuth-sensitive
3/umbraco* or CMS hostsBypass cacheBackoffice
4/_next/static/*Cache 1 year overrideHashed filenames, safe forever
5/_next/image* or /media/*Cache 30 days overridePurged on content change
6All Savoy frontend hosts (catch-all)Respect origin s-maxageHTML pages cached 1 year via headers

Script: scripts/cloudflare/setup-cache-rules.sh (idempotent, PUT to /rulesets/phases/http_request_cache_settings/entrypoint) Token permission: Zone.Cache Rules:Edit

5.1 Free plan (DEV/STAGE) — 1 combined rule

Section titled “5.1 Free plan (DEV/STAGE) — 1 combined rule”
MatchLimitMitigation
/api/forms/* POST OR /api/gate/login POST on Savoy hosts2 req / 10s per IP10s block

Free plan limitations:

  • Only 1 rule total
  • Period fixed at 10 seconds
  • Mitigation timeout minimum 10 seconds

5.2 Pro plan (QA/PROD) — 3 granular rules

Section titled “5.2 Pro plan (QA/PROD) — 3 granular rules”
#MatchLimitMitigation
1/api/forms/* POST5 req / 60s per IP60s
2/api/search20 req / 60s per IP60s
3/api/gate/login POST10 req / 60s per IP300s (anti-brute-force)

Script: scripts/cloudflare/setup-rate-limits.sh with CF_PLAN=free|pro Token permission: Zone.Zone WAF:Edit

  • Stateless Next.js: In-memory counters break across multi-instance deployments (PROD runs 2-6 instances). See .claude/rules/anti-patterns.md #91.
  • Origin protection: Blocks abusive traffic before it consumes Azure CPU/bandwidth.
  • Horizontal scaling: No coordination needed between instances.
  • IP extraction in the app: Still done via CF-Connecting-IP / X-Forwarded-For headers, but ONLY for audit logging, never enforcement.

Purge is triggered entirely from Umbraco via notification handlers. No Next.js webhook involved.

See docs/dev-backend-guides/04_WEBHOOKS_AND_CACHE_PURGE.md for full details. Components:

  • CachePurgeComposer — DI registration + notification handlers (ContentPublished, ContentUnpublished, MediaSaved)
  • CachePurgeService — Fire-and-forget orchestration (with ExecutionContext.SuppressFlow to isolate scope from notification thread)
  • ContentDependencyResolver — Classifies content, resolves URL paths (with IShortStringHelper.CleanStringForUrlSegment for diacritic handling), walks content tree, searches JSON for GUIDs
  • CloudflareClient — API calls (batched 30 URLs per request), warmup (GET fire-and-forget, cap 30), PurgeAllAsync for zone-wide purge
  • PurgeLogRepository — NPoco-based SQL reads/writes to SavoyPurgeLog table (Timestamp must be datetimeoffset(7), not datetime)
  • PurgeLogCleanupService — BackgroundService deleting entries >30 days old
#TriggerScope
S1Publish regularPage (contentPage)Page URL (PT+EN) on affected site
S2Publish detailPage (room/dining/wellness/experience/event/offer)Page + parent list page
S3Publish listPageList page URL
S4Publish siteRootALL descendant pages on that site (+ /404 /500)
S5Publish repositoryItem (eventItem, faqItem, etc.)JSON GUID search → pages referencing the item GUID or parent repo GUID
S6Save mediaJSON GUID search → pages referencing the media GUID + direct media URLs across all sites
S7Unpublish any pageSame as S1/S2 (by content type)
S8Error pagesIncluded in S4 as /404 and /500

Dashboard “Cache & Purge” tab has:

  • Purge All button (with confirmation modal) → calls POST /umbraco/api/savoy-dashboard/purge-all
  • Purge by URL input → calls POST /umbraco/api/savoy-dashboard/purge-urls (max 100)
  • Recent Purge Events list (from SavoyPurgeLog)
  • KPI cards: Cache Hit Rate, Bandwidth Saved, Purges (24h), URLs Purged

Dashboard “Cache Purge” tab (renamed from “Webhook Config”) shows:

  • CachePurge enabled/disabled
  • Cloudflare Zone ID configured
  • API Token configured
  • PurgeLog table available
  • Subscribed notification events
  • Supported purge scenarios (S1-S7)
  • Last purge event

Debug endpoint: GET /umbraco/api/savoy-dashboard/debug/purge-log (direct SQL read, no cache)

  • TTFB (cached HTML): < 50ms (verified on DEV: ~45-53ms)
  • TTFB (cached vs origin): 55% improvement (53ms vs 118ms)
  • Cache hit ratio: > 80% (monitored via Cloudflare Observatory + Dashboard Performance tab)
  • Purge latency: < 2s from publish to Cloudflare API completion
  • Warmup coverage: 30 URLs max per event (fire-and-forget)

Monitoring:

  • Umbraco Dashboard → Cache & Purge tab (PurgeLog SQL, Cloudflare GraphQL)
  • Umbraco Dashboard → Performance tab (Cloudflare Observatory, Web Vitals)
  • Azure Application Insights (origin latency, errors)
  • CMS-native cache purge operational (no Next.js webhook)
  • 8 purge scenarios covered by ContentDependencyResolver
  • PurgeLog writes and reads working (DateTimeOffset type fixed)
  • Dashboard tabs functional (Cache & Purge + Cache Purge config)
  • Cache Rules deployed (6 rules via API on wycreative.com)
  • HTML cached at edge (verified cf-cache-status: HIT)
  • TTFB < 50ms on cached pages
  • ARR Affinity disabled (prerequisite for edge caching)
  • In-memory rate limiter removed from Next.js
  • Rate limiting at Cloudflare WAF layer
  • Pro plan provisioned for QA (before QA go-live)
  • Pro plan provisioned for PROD (before PROD go-live)
  • 3 granular rate limit rules (deployed via CF_PLAN=pro)
  • Image optimization enabled on Pro (Polish, Mirage, WebP auto)
  • Cloudflare Pro upgrade — Tracked in docs/todo/todo-infra.md § 2.1. Required before QA go-live.
  • Rate limit tuning — Current DEV rule (2 req/10s) is strict; monitor false positives in user testing.
  • Zone strategy — Decide if production uses wycreative.com (shared) or savoysignature.com + hotelnext.pt (dedicated). Impacts rate limit scope and cost.