Cloudflare Cache Rules Configuration
Created: 2026-04-12 Zone:
wycreative.com(shared with other WY projects — rules MUST be scoped to Savoy hostnames)
Why Cache Rules?
Section titled “Why Cache Rules?”Cloudflare’s default cache behavior caches static assets (images, JS, CSS) but does NOT cache HTML by default unless explicitly told. Cache Rules are the modern way (replacing Page Rules) to configure per-pattern cache behavior.
Hostname Scope
Section titled “Hostname Scope”The wycreative.com zone serves multiple projects. All cache rules below MUST match only Savoy hostnames:
http.host in { "savoy-dev.wycreative.com" "hotelnext-dev.wycreative.com" "savoy-dev-cms.wycreative.com" "savoy-stage.wycreative.com" "hotelnext-stage.wycreative.com" "savoy-stage-cms.wycreative.com" "www.savoysignature.com" "savoysignature.com" "hotelnext.pt"}Required Rules (in order)
Section titled “Required Rules (in order)”Cloudflare evaluates Cache Rules top-down with last-match-wins semantics: when multiple rules match the same request, the cache settings of the last matching rule win. This is why rule 7 (the DEV/STAGE bypass) sits at the bottom — it overrides the positive HTML cache rule (rule 6) for gated hosts.
Rule 1: Bypass API routes
Section titled “Rule 1: Bypass API routes”Description: Bypass cache for dynamic API endpoints (forms, search, preview, webhooks)
When incoming requests match:
(http.host in {"savoy-dev.wycreative.com" "hotelnext-dev.wycreative.com" "www.savoysignature.com" "hotelnext.pt"}) and (starts_with(http.request.uri.path, "/api/"))Then:
- Cache eligibility: Bypass cache
Rule 2: Bypass dev auth gate
Section titled “Rule 2: Bypass dev auth gate”Description: Gate pages are auth-sensitive (JWT cookies, per-user)
When incoming requests match:
(http.host in {"savoy-dev.wycreative.com" "hotelnext-dev.wycreative.com" "savoy-stage.wycreative.com" "hotelnext-stage.wycreative.com"}) and (starts_with(http.request.uri.path, "/gate/"))Then:
- Cache eligibility: Bypass cache
Rule 3: Bypass Umbraco backoffice
Section titled “Rule 3: Bypass Umbraco backoffice”Description: Never cache Umbraco backoffice or Management API
When incoming requests match:
(http.host in {"savoy-dev-cms.wycreative.com" "savoy-stage-cms.wycreative.com"}) or (starts_with(http.request.uri.path, "/umbraco"))Then:
- Cache eligibility: Bypass cache
Rule 4: Long cache for Next.js static assets
Section titled “Rule 4: Long cache for Next.js static assets”Description: Hashed filenames — safe to cache forever
When incoming requests match:
(http.host in {"savoy-dev.wycreative.com" "hotelnext-dev.wycreative.com" "www.savoysignature.com" "hotelnext.pt"}) and (starts_with(http.request.uri.path, "/_next/static/"))Then:
- Cache eligibility: Eligible for cache
- Edge TTL: Override origin →
1 year (31536000s) - Browser TTL: Override origin →
1 year
Rule 5: Medium cache for optimized images + media
Section titled “Rule 5: Medium cache for optimized images + media”Description: Next.js _next/image and Umbraco /media/* proxy
When incoming requests match:
(http.host in {"savoy-dev.wycreative.com" "hotelnext-dev.wycreative.com" "www.savoysignature.com" "hotelnext.pt"}) and (starts_with(http.request.uri.path, "/_next/image") or starts_with(http.request.uri.path, "/media/"))Then:
- Cache eligibility: Eligible for cache
- Edge TTL: Override origin →
30 days (2592000s) - Browser TTL: Respect existing headers
Rule 6: Cache SSR HTML pages (respect origin headers)
Section titled “Rule 6: Cache SSR HTML pages (respect origin headers)”Description: Cache all HTML pages based on s-maxage from Next.js. Purge via CachePurgeService. Applies to ALL FE hosts (DEV + STAGE + PROD) — rule 7 below overrides for gated hosts.
When incoming requests match:
(http.host in {"savoy-dev.wycreative.com" "hotelnext-dev.wycreative.com" "savoy-stage.wycreative.com" "hotelnext-stage.wycreative.com" "www.savoysignature.com" "savoysignature.com" "hotelnext.pt"})Then:
- Cache eligibility: Eligible for cache
- Edge TTL: Respect origin TTL (uses
s-maxagefrom Next.js headers = 1 year) - Browser TTL: Respect existing headers (
max-age=0= revalidate)
Rule 7: Bypass HTML on DEV/STAGE hosts (gate enabled)
Section titled “Rule 7: Bypass HTML on DEV/STAGE hosts (gate enabled)”Description: MUST be the last rule. Overrides rule 6 for gated hosts so the dev auth gate runs on every HTML request. Static assets are excluded so they remain cacheable via rules 4 and 5. cache-test-mode.sh toggles this rule for cache testing.
When incoming requests match:
(http.host in {"savoy-dev.wycreative.com" "hotelnext-dev.wycreative.com" "savoy-stage.wycreative.com" "hotelnext-stage.wycreative.com"}) and not ( starts_with(http.request.uri.path, "/_next/") or starts_with(http.request.uri.path, "/media/") or http.request.uri.path eq "/favicon.ico" or http.request.uri.path eq "/robots.txt" or starts_with(http.request.uri.path, "/sitemap") )Then:
- Cache eligibility: Bypass cache
How to Create via Dashboard
Section titled “How to Create via Dashboard”- Open Cloudflare Dashboard → Select
wycreative.comzone - Caching → Cache Rules (left sidebar)
- Click Create rule
- For each rule above:
- Name:
Savoy: <rule description> - Paste the “When” expression in the Expression Editor (use the “Edit expression” button for raw mode)
- Configure the “Then” action
- Click Deploy
- Name:
- Ensure rules are in the order listed (drag to reorder)
How to Create via API
Section titled “How to Create via API”Requires an API token with Zone.Cache Rules.Edit permission.
Create token: Dashboard → My Profile → API Tokens → Create Token → Custom token:
- Permissions:
Zone.Cache Rules.Edit - Zone Resources:
Include — Specific zone — wycreative.com - Client IP filtering: none
- TTL: Set expiry or no expiry
Run the script:
export CF_API_TOKEN="your_token_here"export CF_ZONE_ID="<CF_ZONE_ID_DEV>"bash scripts/cloudflare/setup-cache-rules.shThe script at scripts/cloudflare/setup-cache-rules.sh creates all 7 rules via the API.
Verification
Section titled “Verification”After creating the rules, verify they work:
# SSR HTML should be cachedcurl -I "https://savoy-dev.wycreative.com/pt/" | grep -i "cf-cache-status"# Expected: cf-cache-status: HIT (after first request)
# API routes should bypasscurl -I "https://savoy-dev.wycreative.com/api/health" | grep -i "cf-cache-status"# Expected: cf-cache-status: BYPASS or DYNAMIC
# Static assets should hitcurl -I "https://savoy-dev.wycreative.com/_next/static/chunks/main.js" | grep -i "cf-cache-status"# Expected: cf-cache-status: HITAlso check the dashboard’s Analytics → Caching tab for cache hit ratio — goal is >95% for HTML and static assets.
Why These Settings?
Section titled “Why These Settings?”The cache strategy is:
-
Next.js sends
Cache-Control: public, max-age=0, s-maxage=31536000, stale-while-revalidate=60max-age=0— browser revalidates on every request (fresh content)s-maxage=31536000— shared caches (Cloudflare) hold for 1 yearstale-while-revalidate=60— serve stale while refreshing
-
Cloudflare respects
s-maxage(via Rule 6) — holds HTML indefinitely -
CachePurgeService invalidates URLs via Cloudflare API when content is published — ensures freshness without TTL-based expiry
This combination gives us:
- < 50ms TTFB for cached pages (Cloudflare edge)
- 100% freshness — content updates propagate within seconds of publish
- Zero origin load for repeat requests
- Graceful degradation — stale content served for 60s during revalidation
Prerequisite: ARR Affinity (
clientAffinityEnabled) MUST be disabled on the Next.js App Service (infra/modules/app-service-nextjs.bicep). When ARR Affinity is on, Azure sets anARRAffinitysession cookie on every response, which Cloudflare treats as user-specific and refuses to cache. Disabling it was a hard requirement for edge HTML caching to work.
Rate Limiting
Section titled “Rate Limiting”Updated: 2026-04-13 — Rate limit rules are now plan-dependent. The
wycreative.comzone used for DEV/STAGE is on the Cloudflare Free plan; QA/PROD will use Cloudflare Pro (provisioning pending). See PRD02_Infrastructure_and_Environments.mdand PRD15_Security_and_Data_Protection.mdfor the plan matrix.
Rate limiting is enforced at the Cloudflare WAF layer via Rate Limiting Rules (phase: http_ratelimit). The Next.js app is fully stateless and does NOT run an in-memory rate limiter.
Why at the edge:
- In-memory counters break across multi-instance App Service deployments (each instance has its own counter).
- Edge rate limiting saves origin CPU/bandwidth — abusive traffic never reaches Azure.
- Enables horizontal autoscaling (PROD: min 2 / max 6 instances) without coordination.
Plan-dependent rule sets
Section titled “Plan-dependent rule sets”The two environments run different rule sets because the Free plan has hard constraints that don’t allow the granular setup planned for QA/PROD.
| Constraint | Free (DEV/STAGE) | Pro (QA/PROD, ~$20/mo) |
|---|---|---|
| Max rate limit rules | 1 per zone | 10 per zone |
| Available periods | 10s only | 10s / 60s / 600s / 3600s |
| Min mitigation timeout | 10s | 10s (up to 3600s) |
| Cache Rules | Limited | Full feature set |
| Transform Rules | 10 | 50 |
| Page Rules | 3 | 25 |
| Image optimization (Polish/WebP/Mirage) | — | Included |
| Browser Integrity Check / Rocket Loader | — | Included |
| Analytics retention | 1× | 3× |
Managed by scripts/cloudflare/setup-rate-limits.sh
Section titled “Managed by scripts/cloudflare/setup-rate-limits.sh”The script PUTs the entire rate limiting ruleset via the Cloudflare API — idempotent, safe to re-run. It accepts a CF_PLAN environment variable (free or pro, default: free) and switches between the two rule sets automatically.
Token permissions required:
Zone.Rate Limit Rules:EditZone.Zone:Read
Usage — DEV/STAGE (Free plan, default):
export CF_API_TOKEN="your_token_here"export CF_ZONE_ID="<CF_ZONE_ID_DEV>"export CF_PLAN="free" # optional — this is the defaultbash scripts/cloudflare/setup-rate-limits.shUsage — QA/PROD (Pro plan):
export CF_API_TOKEN="your_token_here"export CF_ZONE_ID="<qa_or_prod_zone_id>"export CF_PLAN="pro"bash scripts/cloudflare/setup-rate-limits.shHostname scope
Section titled “Hostname scope”All rate limit rules apply to the same Savoy frontend hostnames:
{ "savoy-dev.wycreative.com" "hotelnext-dev.wycreative.com" "savoy-stage.wycreative.com" "hotelnext-stage.wycreative.com" "www.savoysignature.com" "savoysignature.com" "hotelnext.pt"}Free plan (DEV/STAGE) — 1 combined rule
Section titled “Free plan (DEV/STAGE) — 1 combined rule”The Free plan allows only one rate limit rule per zone, with a fixed 10-second period. The compromise is a single rule that matches both forms and gate-login traffic, with a tight limit.
Rule: Forms + Gate login — 2 req / 10s per IP
Section titled “Rule: Forms + Gate login — 2 req / 10s per IP”Description: Savoy: Forms + Gate login rate limit (2 req / 10s per IP) [Free plan]
Purpose: Combined protection against form spam (M14 Contact, M33 Newsletter) and Dev Auth Gate brute-force. Granular per-endpoint rules are not possible on Free — QA/PROD get the 3-rule set once Pro is provisioned.
Expression:
(http.host in {<Savoy frontend hosts>}) and ( (starts_with(http.request.uri.path, "/api/forms/") and http.request.method eq "POST") or (starts_with(http.request.uri.path, "/api/gate/login") and http.request.method eq "POST") )Characteristics: ip.src, cf.colo.id
Period: 10s · Limit: 2 requests · Mitigation timeout: 10s · Action: block
Search endpoint (
/api/search) is not rate-limited on Free — the endpoint doesn’t yet exist, and adding it would consume the single available rule slot.
Pro plan (QA/PROD) — 3 granular rules
Section titled “Pro plan (QA/PROD) — 3 granular rules”Once Cloudflare Pro is provisioned for QA/PROD, the script creates three granular rules with 60-second periods and per-endpoint tuning.
Rule 1: Forms — 5 req / 60s per IP
Section titled “Rule 1: Forms — 5 req / 60s per IP”Purpose: Block form spam and brute-force submissions on /api/forms/* POST endpoints (M14 Contact, M33 Newsletter).
Expression:
(http.host in {<Savoy frontend hosts>}) and (starts_with(http.request.uri.path, "/api/forms/")) and (http.request.method eq "POST")Characteristics: ip.src, cf.colo.id
Period: 60s · Limit: 5 requests · Mitigation timeout: 60s · Action: block
Rule 2: Search — 20 req / 60s per IP
Section titled “Rule 2: Search — 20 req / 60s per IP”Purpose: Prevent scraping and DoS on the search endpoint. Only deployed when the endpoint exists.
Expression:
(http.host in {<Savoy frontend hosts>}) and (starts_with(http.request.uri.path, "/api/search"))Characteristics: ip.src, cf.colo.id
Period: 60s · Limit: 20 requests · Mitigation timeout: 60s · Action: block
Rule 3: Gate login — 10 req / 60s per IP (5 min timeout)
Section titled “Rule 3: Gate login — 10 req / 60s per IP (5 min timeout)”Purpose: Anti-brute-force on the Dev Auth Gate login endpoint. The longer mitigation timeout (300s vs 60s on the other rules) intentionally penalises repeated credential-stuffing attempts.
Expression:
(http.host in {<Savoy frontend hosts>}) and (starts_with(http.request.uri.path, "/api/gate/login")) and (http.request.method eq "POST")Characteristics: ip.src, cf.colo.id
Period: 60s · Limit: 10 requests · Mitigation timeout: 300s · Action: block
Verification
Section titled “Verification”After running the script, verify the rules took effect:
# Should return 429 Too Many Requests after hitting the limitfor i in {1..12}; do curl -s -o /dev/null -w "%{http_code}\n" -X POST https://savoy-dev.wycreative.com/api/gate/logindoneCheck the Cloudflare dashboard: Security → WAF → Rate limiting rules. Hit the “Events” tab to see blocked requests.
IP extraction in the app
Section titled “IP extraction in the app”The app still reads CF-Connecting-IP / X-Forwarded-For headers to log the originating IP — but only for audit logging (form submissions, gate login attempts, security events). It is NEVER used for enforcement. If an application reviewer suggests restoring the in-memory rate limiter, refuse — it breaks multi-instance deployments.
Cache Test Mode (DEV only)
Section titled “Cache Test Mode (DEV only)”Updated: 2026-04-13 — Script
scripts/cloudflare/cache-test-mode.shadded to temporarily disable the DEV auth gate so Cloudflare can fully exercise HTML edge caching.
Why it exists
Section titled “Why it exists”On DEV, two layers conspire to keep HTML out of the edge cache:
- Dev auth gate — the Next.js middleware redirects unauthenticated requests to
/gate/loginbefore any page can render - Cache Rule 7 —
Bypass HTML on DEV/STAGE hosts (gate enabled)overrides the positive HTML cache rule so even authenticated traffic is not cached, preventing accidental disclosure of gated content to anonymous users
To exercise the cache + purge flow end-to-end on DEV, both layers must be flipped off in lockstep. Cache Test Mode does exactly that:
- Sets
GATE_ENABLED=falseon the DEV App Service - Disables Cache Rule 7 in Cloudflare (which leaves rule 6 — the positive HTML cache — active for DEV)
- Purges the Cloudflare cache so the next requests hit origin and warm the edge cleanly
A background timer auto-reverts after 2 hours by default — DEV cannot be left publicly accessible by accident.
When to use it
Section titled “When to use it”- Validating the end-to-end cache / purge flow from Umbraco → Cloudflare → browser
- Measuring TTFB improvements from edge HIT vs origin
- Debugging why specific pages are not being cached
- Testing a new cache rule change
When NOT to use it
Section titled “When NOT to use it”- Never on STAGE, QA, or PROD — the script only targets
app-nextjs-savoy-dev - Never while the team is demoing to the client — DEV goes public during test mode
- Never leave it enabled overnight — the 2h timeout exists for a reason
Prerequisites
Section titled “Prerequisites”# 1. Cloudflare token — needs both:# - Zone.Cache Rules:Edit (to toggle rule 7)# - Zone.Cache Purge (to purge cache after toggling)export CF_API_TOKEN="..."
# 2. Azure CLI logged in with access to rg-savoy-devaz loginaz account show # verify correct subscriptionToken note: The token used for
setup-cache-rules.shandsetup-rate-limits.shhasZone.Cache Rules:EditandZone.WAF:Edit, but notZone.Cache Purge. The cache test mode script still works without the purge permission — it logs a warning and skips the purge step. Cached pages will then expire via TTL or you can purge manually from the Umbraco dashboard’s Cache & Purge tab. For the smoothest experience, create a separate token with all three permissions or extend the existing one.
Commands
Section titled “Commands”# Enable with default 2h timeoutbash scripts/cloudflare/cache-test-mode.sh enable
# Enable with a custom timeoutbash scripts/cloudflare/cache-test-mode.sh enable --timeout 30mbash scripts/cloudflare/cache-test-mode.sh enable --timeout 4h
# Check current statebash scripts/cloudflare/cache-test-mode.sh status
# Revert immediately (cancels auto-revert timer)bash scripts/cloudflare/cache-test-mode.sh disableWhat each command does
Section titled “What each command does”enable:
- Arms the auto-revert timer FIRST — spawns a detached background process and writes state to
~/.config/savoy/cache-test-mode.state(PID, expiry). The timer is the safety net: if any subsequent step fails, the revert still runs on schedule. - Sets
GATE_ENABLED=falseonapp-nextjs-savoy-dev - Restarts the App Service (~30s downtime)
- Purges the entire Cloudflare cache for the zone (best-effort — failure logs a warning but does not abort)
disable:
- Kills the auto-revert timer if still running
- Sets
GATE_ENABLED=trueon the App Service - Restarts the App Service
- Purges the Cloudflare cache
- Removes the state file
status:
- Reads the live
GATE_ENABLEDvalue from Azure (not the state file — it’s the source of truth) - Checks the state file for the timer PID and remaining time
- Reports whether the mode is active, and how long until auto-revert
Verification
Section titled “Verification”After running enable, verify Cloudflare is actually caching:
# First request — should show MISScurl -sI "https://savoy-dev.wycreative.com/pt/" | grep -iE "cf-cache-status|x-cache"
# Wait 2s, then repeat — should show HITcurl -sI "https://savoy-dev.wycreative.com/pt/" | grep -iE "cf-cache-status"# Expected: cf-cache-status: HITThen test the purge flow by publishing a page in Umbraco and watching the Cloudflare cache invalidate for that URL. Check the Cache & Purge tab in the Umbraco dashboard for purge log entries.
Safety notes
Section titled “Safety notes”- The auto-revert timer uses
nohup+disown, so it survives closing the terminal — but it does not survive a host reboot. If you reboot your machine mid-session, runstatusto check anddisablemanually. - State is per-machine (
~/.config/savoy/cache-test-mode.state). If another developer enabled the mode from their machine, your local status check won’t know about their timer — always query Azure (statusdoes this automatically). - The script targets only
app-nextjs-savoy-devandrg-savoy-dev. It does not touch STAGE, QA, or PROD. Do not adapt it to cover those environments — Cache Test Mode is a DEV-only tool.