Skip to content

Cloudflare Infrastructure

This document covers our Cloudflare integration for the Heatwave project.

We use Cloudflare for:

  • Edge Caching - CDN and cache purging
  • Workers - Edge compute for locale redirects, R2 asset serving, and asset versioning
  • Security - WAF, rate limiting, bot protection
  • Tunnels - Secure connection between origin and Cloudflare (see README_CLOUDFLARED.md)
  • DNS - Domain management
  • Stream - Video hosting and delivery
  • Access - Zero Trust access control (staging)
Environment Domain Zone ID Plan
Production warmlyyours.com cd991c21281a518afa7a0822dca3d434 Business
Staging warmlyyours.ws d39acaed475782c4901d4a8e5908c1cb Pro
Development warmlyyours.me 4a387e118ce5ea4917eb8dc724f3e3a9 Free
79b7f58cf035093b5ad11747df30369a

Tokens are stored in 1Password under “Cloudflare Account API Token - Heatwave - All Zones”:

Field Purpose
credential Main API token for rules sync and zone management
stream_token Cloudflare Stream and Images API
legacy_token Legacy operations (deprecated)

Token Management: https://dash.cloudflare.com/79b7f58cf035093b5ad11747df30369a/api-tokens

For full rules sync functionality, the API token needs:

Zone Permissions:

  • Zone.Zone (Read)
  • Zone.Zone Settings (Read/Edit)
  • Zone.Firewall Services (Read/Edit)
  • Zone.Zone Rulesets (Read/Edit)
  • Zone.Rate Limiting (Read/Edit)
  • Zone.Cache Rules (Edit)
  • Zone.Transform Rules (Edit)
  • Zone.Single Redirect (Edit)

Account Permissions:

  • Account Rulesets (Edit)

Location: app/services/cache/edge_cache_utility.rb

Provides cache purging functionality:

# Purge specific URLs
Cache::EdgeCacheUtility.instance.purge_url("https://www.warmlyyours.com/page")
# Purge by cache tags
Cache::EdgeCacheUtility.instance.purge_by_tags("post", "product-123")
# Purge everything (use sparingly!)
Cache::EdgeCacheUtility.instance.purge_everything(:production)

Rake Task:

Terminal window
mise exec -- rake edge_cache:purge_everything

Zone rulesets are managed in Terraform (HCP Terraform)

Section titled “Zone rulesets are managed in Terraform (HCP Terraform)”

Source of truth: infra/terraform/cloudflare-zone-production/ (warmlyyours.com) and infra/terraform/cloudflare-zone-staging/ (warmlyyours.ws) — one cloudflare_ruleset resource per zone-owned phase: cache settings, WAF managed overrides, custom firewall rules, rate limits, config settings, header transforms, single redirects. Rule order and enabled state are part of the code, so changes are PR-reviewed (HCP Terraform posts a speculative plan as a PR check) and dashboard drift is detected on every plan.

Workflow: edit HCL → PR → review the speculative plan → merge → confirm apply in the TFC UI (workspaces heatwave-cloudflare-zone-{production,staging}, auto-apply off). Treat the dashboard as read-only for these phases. See infra/terraform/README.md for the workspace table and import bootstrap.

Cloudflare Rules Service (DEPRECATED for rulesets)

Section titled “Cloudflare Rules Service (DEPRECATED for rulesets)”

Location: app/services/cloudflare_rules_service.rb

The rake-based export/compare/sync (mise exec -- rake cloudflare:rules:..., YAML exports under data/cloudflare_rules/) is superseded by the Terraform modules above for everything rulesets-based. It remains only for the legacy firewall_rules API (deprecated by Cloudflare itself) and ad-hoc page-rules export until it is removed — do not use it to mutate rulesets anymore; a sync would fight Terraform state. The bulk-redirect CSVs in data/cloudflare_rules/ are unrelated (owned by the cloudflare-redirects skill) and stay.

Location: app/services/cloudflare_stream_api.rb

Manages video hosting on Cloudflare Stream:

api = CloudflareStreamApi.instance
api.get_video(cloudflare_uid)
api.enable_mp4_downloads(cloudflare_uid)

Cloudflare Workers run custom JavaScript at the edge, before requests hit the origin or cache.

Location: cloudflare-worker/

Worker Config Purpose
www-edge wrangler-www-edge.toml Locale redirects + R2 webpack assets + Asset versioning + Turbo debug headers

A combined worker that handles locale redirects, R2-backed webpack asset serving, asset versioning, and Turbo debug headers. Cache isolation for Turbo and query-variant pages is owned by zone cache rules, not the worker.

Feature 1: Turbo request handling (zone rules own the isolation)

Section titled “Feature 1: Turbo request handling (zone rules own the isolation)”

Problem: Cloudflare uses URL as the cache key by default. But the same URL can return different content:

  • Regular HTML (normal page loads)
  • Turbo Stream (Accept: text/vnd.turbo-stream.html)
  • Turbo Frame (with Turbo-Frame header)

Without isolation, a Turbo response could be cached and served for a regular HTML request, breaking pages.

How it’s actually handled (2026-06-12):

  • Turbo Stream requests are kept out of the edge cache entirely by the zone cache rule “Bypass Cache Turbo Stream Request” (matches on the Accept: text/vnd.turbo-stream.html header). They can neither poison a page’s entry nor be served from one.
  • Turbo Frame requests intentionally share the page’s cache entry: Rails renders the full page for frame requests (Turbo extracts the frame client-side), and the app’s lazy frames (lead-form modals, infinite scroll) point at uncacheable endpoints anyway — so a frame response cached under the page key is byte-equivalent to the regular page.
  • The worker only stamps X-Turbo-Cache-Key / Vary: Accept debug headers on turbo-handled responses.

An earlier worker revision set fetch cf.cacheKey to a __turbo-suffixed URL to give Turbo responses separate cache entries. request.cf cache settings only take precedence over zone Cache Rules when the request_cf_overrides_cache_rules compatibility flag is active (default-on 2025-04-02); the worker pins compatibility_date = 2024-04-29, so that key was silently ignored. The inert override was removed on 2026-06-12 (same incident as Feature 2 below).

Feature 2 (RETIRED): Query-sensitive HTML cache key (/reviews, /towel-warmer)

Section titled “Feature 2 (RETIRED): Query-sensitive HTML cache key (/reviews, /towel-warmer)”

Problem: the zone cache rule “Cache Localized pages on www non paginated” caches localized www HTML while excluding all query parameters from the edge cache key (except page= / q[-style cases, which other rules handle). URLs like /en-US/towel-warmer?sort=effective_price asc collapsed onto the unsorted page’s cache entry — sorting/filter-sorting appeared dead, and a sorted/filtered origin response could even get stored under the base page’s cache key, serving sorted content to everyone for the TTL.

Old (broken) solution: the worker set fetch cf.cacheKey to the full URL for these paths. That was silently ignored: request.cf cache settings only override zone Cache Rules when the request_cf_overrides_cache_rules compatibility flag is active (default-on 2025-04-02), and the worker pins compatibility_date = 2024-04-29 — so the “Cache Localized pages” rule kept winning. In that flag-off state the override was also observed interacting with the rules to store a sorted/filtered response under the base page’s stripped cache key — the poisoning above. Removed from the worker on 2026-06-12. There is no reason to reintroduce it — the cache rules below deliver per-variant caching without it. If a worker-side cache key is ever genuinely needed, it requires deliberately enabling that compatibility flag (do NOT just bump the compatibility date — that activates a year of other flags) and verifying the rule/worker precedence on staging (.ws) first.

Current solution (zone cache rules, no worker involvement): the same pattern the page= rules already use. The “Cache Localized pages on www non paginated” rule’s expression carves out reviews/towel-warmer requests whose query carries content-affecting params (sort/after/min_rating on /reviews; sort on /towel-warmer), and the paired rule “Cache www reviews/towel-warmer sort/cursor variants per full URL” caches them with the default cache key — which includes the full query string — so each sort variant gets its own edge entry (these are heavy pages; per-variant caching keeps them fast). Verified live: each variant MISSes once then HITs its own entry; the query-less base page entry is unaffected.

Maintenance invariants:

  • Keep the carve-out condition in the “Cache Localized…” expression and the variants rule’s expression identical — if they drift, drifted URLs either collapse onto stripped keys again (bug) or merely go uncached (safe but slow).
  • The variants rule must stay before the “Bypass Cache Turbo Stream Request” / “Bypass cache for search/filter requests” rules — later cache rules override earlier ones, and turbo/q[ requests must keep bypassing.
  • Keep the param list aligned with Www::ReviewsController and Www::TowelWarmersController#ALLOWED_SORTS.
  • A custom include-all-query cache key on the variants rule would be cleaner (no carve-out needed), but the API rejects it on this plan: “not entitled to use the custom cache key override” (the existing exclude-all keys predate the entitlement change).

Paginated sorted pages (?page=2&sort=…) are cached by the “Cache Paginated WWW pages including query string” rule under their full URL (default cache key), so each sort variant of a page is cached separately and correctly.

Ensures browsers re-fetch HTML when webpack assets change — without ever touching per-user pages.

The origin owns the caching decision. Asset versioning is applied only to HTML the origin marked cacheable: its Cache-Control must be present and must not contain no-store, no-cache, or private. Anything else — the cart, checkout, account, search, every per-user page — is returned byte-for-byte untouched. The worker does not override the origin.

How it works (origin-cacheable HTML only):

  1. Worker fetch()es the response, then computes a hash of assets-manifest.json (cached 60s at edge).
  2. Checks the response’s Cache-Control — if not cacheable, returns it untouched and stops here.
  3. Otherwise attaches ETag: "manifest-<hash>" and Cache-Control: no-cache.
  4. On revisit, if the browser’s If-None-Match matches the current manifest hash, returns 304 Not Modified.
  5. On deploy (manifest changes), the hash changes → browser gets fresh HTML → loads the new bundles.

Why this works:

  • Webpack bundles carry a content hash in the filename (e.g. runtime.a2bed5cc.js) — they’re immutable; their URLs change with content.
  • HTML references those bundles via <script> tags.
  • When the manifest changes the ETag changes, so the browser fetches fresh HTML with the new bundle URLs.
  • Old bundles accumulate in the heatwave-frontend-assets-* R2 bucket (served same-origin by this worker), so stale HTML still works during a rollout.

Why the origin-cacheability gate matters: an earlier version stamped the manifest ETag onto every HTML response, including /my_cart. The manifest hash is content-independent, so the worker answered the browser’s revalidation of the cart with a 304 keyed only on the asset version — serving a stale, pre-mutation cart even though the line item had saved at the origin. The gate — plus removing a header-blind pre-fetch() 304 fast-path — fixed it: the worker now always fetches the response and inspects the origin’s Cache-Control before deciding.

Current Production Route:

routes = [
{ pattern = "www.warmlyyours.com/*", zone_id = "cd991c21281a518afa7a0822dca3d434" }
]

The worker catches all requests but only modifies HTML responses. Non-HTML responses (JS, CSS, images) pass through unchanged.

Note: The worker only adds the asset-version ETag to text/html responses the origin marked cacheable (see Feature 3). Non-HTML responses, and per-user HTML the origin marked no-store/no-cache/private, pass through unmodified. Static assets with content-hash filenames (e.g., runtime.a2bed5cc.js) don’t need asset versioning since their URLs change when content changes.

Logging is enabled for debugging:

[observability.logs]
enabled = true
head_sampling_rate = 1
persist = true
invocation_logs = true

View logs via MCP:

# Use cloudflare-observability MCP server to query logs
# Filter by: $metadata.service = "www-edge-production"

View logs via CLI:

Terminal window
cd cloudflare-worker
npx wrangler tail -c wrangler-www-edge.toml --env production --format json

Prerequisites:

Terminal window
# Wrangler is available via npx (no global install needed)
cd cloudflare-worker
# Authenticate (first time only)
npx wrangler login

Deploy:

Terminal window
cd cloudflare-worker
# Deploy to staging first
npx wrangler deploy -c wrangler-www-edge.toml --env staging
# Then production
npx wrangler deploy -c wrangler-www-edge.toml --env production

Check Worker Status:

Terminal window
# List deployments
npx wrangler deployments list -c wrangler-www-edge.toml
# View live logs
npx wrangler tail -c wrangler-www-edge.toml --env production

Disable via Dashboard:

  1. Go to Cloudflare Dashboard
  2. Navigate to: Workers & Pages → www-edge-production → Settings → Routes
  3. Delete or disable the routes

Rollback to Previous Version:

Terminal window
npx wrangler rollback -c wrangler-www-edge.toml --env production

Delete Worker Entirely:

Terminal window
npx wrangler delete -c wrangler-www-edge.toml --env production

Test Asset Versioning (GET/HEAD):

Terminal window
# Check ETag and X-Asset-Version headers on HTML
curl -skI https://www.warmlyyours.com/en-US/floor-heating | grep -iE "ETag|X-Asset|X-Cache|content-type"
# Expected: etag: "manifest-HASH", x-asset-version: HASH, x-cache-status: MISS
# Test 304 response with cached ETag
curl -skI -H "If-None-Match: \"manifest-2126c2d7285e\"" https://www.warmlyyours.com/en-US
# Expected: HTTP/2 304, x-cache-status: REVALIDATED

Test Turbo Cache Key:

Terminal window
# Regular HTML request (no X-Turbo-Cache-Key header)
curl -skI https://www.warmlyyours.com/en-US/products/code/TRT120-01 | grep -iE "X-Turbo|X-Asset"
# Expected: x-asset-version header, NO x-turbo-cache-key
# Turbo Stream request (should have X-Turbo-Cache-Key: stream)
curl -skI -H "Accept: text/vnd.turbo-stream.html" https://www.warmlyyours.com/en-US/products/code/TRT120-01 | grep -iE "X-Turbo"
# Expected: x-turbo-cache-key: stream
# Turbo Frame request (should have X-Turbo-Cache-Key: frame-{id})
curl -skI -H "Turbo-Frame: product-section" https://www.warmlyyours.com/en-US/products/code/TRT120-01 | grep -iE "X-Turbo"
# Expected: x-turbo-cache-key: frame-product-section

Verify Non-HTML Pass-Through:

Terminal window
# JSON/API requests should NOT have worker-added headers
curl -skI https://www.warmlyyours.com/en-US/globals.json | grep -iE "X-Asset|X-Turbo"
# Expected: No X-Asset-Version or X-Turbo-Cache-Key headers
File Purpose
cloudflare-worker/www-edge-worker.js Combined worker: Turbo cache key + Asset versioning
cloudflare-worker/wrangler-www-edge.toml Worker configuration with route optimization

The modern rules engine includes:

Phase Purpose
http_request_cache_settings Cache rules
http_request_firewall_custom Custom WAF rules
http_request_dynamic_redirect Redirect rules
http_response_headers_transform Response header modifications
http_config_settings Configuration overrides
http_ratelimit Rate limiting

Classic firewall rules using expressions. Being phased out in favor of WAF Custom Rules.

Rules Sync: Production → Staging (RETIRED for rulesets)

Section titled “Rules Sync: Production → Staging (RETIRED for rulesets)”

⚠️ Do NOT run mise exec -- rake cloudflare:rules:sync* against rulesets anymore. Both zones’ rulesets are Terraform-managed (see Zone rulesets are managed in Terraform above); a rake sync would mutate them behind Terraform’s back and show up as drift that the next apply reverts. To propagate a rule from production to staging, port it between infra/terraform/cloudflare-zone-production/ and …-staging/ in a PR — adapting hostnames (warmlyyours.comwarmlyyours.ws) and plan differences (matches regex operator requires Business — production only; rate-limit parameters differ by plan) by hand, which the old auto-sync half-handled anyway.

The export/compare halves (mise exec -- rake cloudflare:rules:export_all, … cloudflare:rules:compare) remain harmless read-only tools but are superseded by tofu plan drift detection; the whole service goes away with the legacy firewall_rules API cleanup (follow-up in doc/tasks/202606121035_CLOUDFLARE_RULES_FOLLOWUPS.md).

Cloudflare’s hosted MCP servers are available to any agent that reads the project’s committed .mcp.json (Claude Code, Cursor, Zed, …) — they are not tied to a specific editor. See the mcp-servers skill for how MCP servers are wired up in this repo.

Setup: Run ./script/setup_mcp_servers.sh to populate the gitignored .env.mcp secrets, then start (or restart) your agent so it picks up .mcp.json.

Server Purpose
cloudflare-observability Debug logs and analytics
cloudflare-audit-logs Query audit logs and reports
cloudflare-dns-analytics DNS performance and debugging
cloudflare-graphql Analytics data via GraphQL
cloudflare-docs Up-to-date Cloudflare documentation

Authentication: OAuth via browser on first use.

Documentation: https://developers.cloudflare.com/agents/model-context-protocol/mcp-servers-for-cloudflare/

See README_CLOUDFLARED.md for tunnel setup instructions.

Production Tunnel: heatwave-app-production Staging Tunnel: heatwave-app-staging

Staging environment is protected by Cloudflare Access:

  • Requires authentication to access *.warmlyyours.ws
  • Configured in Cloudflare Zero Trust dashboard

We use Cloudflare’s geo headers for visitor location:

# Headers available in requests:
# CF-IPCountry, CF-IPCity, CF-Region, CF-Postal-Code, etc.

Development Simulation: lib/middleware/cloudflare_geo_simulator.rb

Terminal window
# Test with different geos in development:
TRACK_VISITOR=y TRACK_VISITOR_PROFILE=CA-QC bin/dev

Location: lib/turnstile.rb

Used for bot protection on forms.

File Purpose
app/services/cache/edge_cache_utility.rb Cache purging
app/services/cloudflare_rules_service.rb Rules sync
app/services/cloudflare_stream_api.rb Video streaming
lib/turnstile.rb CAPTCHA verification
lib/middleware/cloudflare_geo_simulator.rb Geo testing
config/initializers/cloudflare.rb Stream/Image token config
config/initializers/cloudflare_geo_simulator.rb Geo middleware setup
lib/tasks/cloudflare_rules.rake Rules sync rake tasks
lib/tasks/edge_cache.rake Cache purge rake tasks
data/cloudflare_rules/ Exported rules storage
Terminal window
# Check token validity
mise exec -- rake cloudflare:rules:check_permissions

If permissions fail:

  1. Go to https://dash.cloudflare.com/79b7f58cf035093b5ad11747df30369a/api-tokens
  2. Edit the token and add missing permissions
  3. Update the token in 1Password and credentials
  1. Verify edge cache is enabled: Cache::EdgeCacheUtility.edge_cache_enabled?
  2. Check zone ID is correct in zone_map
  3. Test with a single URL purge first

Common issues:

  • “not entitled” - Target zone on lower plan, rule uses unsupported operator
  • “exceeded maximum” - Target zone has lower rule limits
  • “request not authorized” - Missing API token permission
Terminal window
# Check tunnel status
cloudflared tunnel list
cloudflared tunnel info <tunnel-name>
# Check service status
systemctl status cloudflared
journalctl -u cloudflared -f