Skip to content

Cloudflare Cache Rules Configuration

Created: 2026-04-12 Zone: wycreative.com (shared with other WY projects — rules MUST be scoped to Savoy hostnames)

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.

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"
}

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.

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

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

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 origin1 year (31536000s)
  • Browser TTL: Override origin1 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 origin30 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-maxage from 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

  1. Open Cloudflare Dashboard → Select wycreative.com zone
  2. Caching → Cache Rules (left sidebar)
  3. Click Create rule
  4. 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
  5. Ensure rules are in the order listed (drag to reorder)

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:

Terminal window
export CF_API_TOKEN="your_token_here"
export CF_ZONE_ID="<CF_ZONE_ID_DEV>"
bash scripts/cloudflare/setup-cache-rules.sh

The script at scripts/cloudflare/setup-cache-rules.sh creates all 7 rules via the API.

After creating the rules, verify they work:

Terminal window
# SSR HTML should be cached
curl -I "https://savoy-dev.wycreative.com/pt/" | grep -i "cf-cache-status"
# Expected: cf-cache-status: HIT (after first request)
# API routes should bypass
curl -I "https://savoy-dev.wycreative.com/api/health" | grep -i "cf-cache-status"
# Expected: cf-cache-status: BYPASS or DYNAMIC
# Static assets should hit
curl -I "https://savoy-dev.wycreative.com/_next/static/chunks/main.js" | grep -i "cf-cache-status"
# Expected: cf-cache-status: HIT

Also check the dashboard’s Analytics → Caching tab for cache hit ratio — goal is >95% for HTML and static assets.

The cache strategy is:

  1. Next.js sends Cache-Control: public, max-age=0, s-maxage=31536000, stale-while-revalidate=60

    • max-age=0 — browser revalidates on every request (fresh content)
    • s-maxage=31536000 — shared caches (Cloudflare) hold for 1 year
    • stale-while-revalidate=60 — serve stale while refreshing
  2. Cloudflare respects s-maxage (via Rule 6) — holds HTML indefinitely

  3. 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 an ARRAffinity session 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.


Updated: 2026-04-13 — Rate limit rules are now plan-dependent. The wycreative.com zone used for DEV/STAGE is on the Cloudflare Free plan; QA/PROD will use Cloudflare Pro (provisioning pending). See PRD 02_Infrastructure_and_Environments.md and PRD 15_Security_and_Data_Protection.md for 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.

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.

ConstraintFree (DEV/STAGE)Pro (QA/PROD, ~$20/mo)
Max rate limit rules1 per zone10 per zone
Available periods10s only10s / 60s / 600s / 3600s
Min mitigation timeout10s10s (up to 3600s)
Cache RulesLimitedFull feature set
Transform Rules1050
Page Rules325
Image optimization (Polish/WebP/Mirage)Included
Browser Integrity Check / Rocket LoaderIncluded
Analytics retention

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:Edit
  • Zone.Zone:Read

Usage — DEV/STAGE (Free plan, default):

Terminal window
export CF_API_TOKEN="your_token_here"
export CF_ZONE_ID="<CF_ZONE_ID_DEV>"
export CF_PLAN="free" # optional — this is the default
bash scripts/cloudflare/setup-rate-limits.sh

Usage — QA/PROD (Pro plan):

Terminal window
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.sh

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"
}

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.

Once Cloudflare Pro is provisioned for QA/PROD, the script creates three granular rules with 60-second periods and per-endpoint tuning.

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


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


After running the script, verify the rules took effect:

Terminal window
# Should return 429 Too Many Requests after hitting the limit
for i in {1..12}; do
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://savoy-dev.wycreative.com/api/gate/login
done

Check the Cloudflare dashboard: Security → WAF → Rate limiting rules. Hit the “Events” tab to see blocked requests.

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.


Updated: 2026-04-13 — Script scripts/cloudflare/cache-test-mode.sh added to temporarily disable the DEV auth gate so Cloudflare can fully exercise HTML edge caching.

On DEV, two layers conspire to keep HTML out of the edge cache:

  1. Dev auth gate — the Next.js middleware redirects unauthenticated requests to /gate/login before any page can render
  2. Cache Rule 7Bypass 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:

  1. Sets GATE_ENABLED=false on the DEV App Service
  2. Disables Cache Rule 7 in Cloudflare (which leaves rule 6 — the positive HTML cache — active for DEV)
  3. 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.

  • 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
  • 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
Terminal window
# 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-dev
az login
az account show # verify correct subscription

Token note: The token used for setup-cache-rules.sh and setup-rate-limits.sh has Zone.Cache Rules:Edit and Zone.WAF:Edit, but not Zone.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.

Terminal window
# Enable with default 2h timeout
bash scripts/cloudflare/cache-test-mode.sh enable
# Enable with a custom timeout
bash scripts/cloudflare/cache-test-mode.sh enable --timeout 30m
bash scripts/cloudflare/cache-test-mode.sh enable --timeout 4h
# Check current state
bash scripts/cloudflare/cache-test-mode.sh status
# Revert immediately (cancels auto-revert timer)
bash scripts/cloudflare/cache-test-mode.sh disable

enable:

  1. 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.
  2. Sets GATE_ENABLED=false on app-nextjs-savoy-dev
  3. Restarts the App Service (~30s downtime)
  4. Purges the entire Cloudflare cache for the zone (best-effort — failure logs a warning but does not abort)

disable:

  1. Kills the auto-revert timer if still running
  2. Sets GATE_ENABLED=true on the App Service
  3. Restarts the App Service
  4. Purges the Cloudflare cache
  5. Removes the state file

status:

  • Reads the live GATE_ENABLED value 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

After running enable, verify Cloudflare is actually caching:

Terminal window
# First request — should show MISS
curl -sI "https://savoy-dev.wycreative.com/pt/" | grep -iE "cf-cache-status|x-cache"
# Wait 2s, then repeat — should show HIT
curl -sI "https://savoy-dev.wycreative.com/pt/" | grep -iE "cf-cache-status"
# Expected: cf-cache-status: HIT

Then 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.

  • 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, run status to check and disable manually.
  • 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 (status does this automatically).
  • The script targets only app-nextjs-savoy-dev and rg-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.