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)
EnvironmentDomainZone IDPlan
Productionwarmlyyours.comcd991c21281a518afa7a0822dca3d434Business
Stagingwarmlyyours.wsd39acaed475782c4901d4a8e5908c1cbPro
Developmentwarmlyyours.me4a387e118ce5ea4917eb8dc724f3e3a9Free
79b7f58cf035093b5ad11747df30369a

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

FieldPurpose
credentialMain API token for rules sync and zone management
stream_tokenCloudflare Stream and Images API
legacy_tokenLegacy 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/

WorkerConfigPurpose
www-edgewrangler-www-edge.tomlLocale 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
FilePurpose
cloudflare-worker/www-edge-worker.jsCombined worker: Turbo cache key + Asset versioning
cloudflare-worker/wrangler-www-edge.tomlWorker configuration with route optimization

The modern rules engine includes:

PhasePurpose
http_request_cache_settingsCache rules
http_request_firewall_customCustom WAF rules
http_request_dynamic_redirectRedirect rules
http_response_headers_transformResponse header modifications
http_config_settingsConfiguration overrides
http_ratelimitRate 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.

ServerPurpose
cloudflare-observabilityDebug logs and analytics
cloudflare-audit-logsQuery audit logs and reports
cloudflare-dns-analyticsDNS performance and debugging
cloudflare-graphqlAnalytics data via GraphQL
cloudflare-docsUp-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.

FilePurpose
app/services/cache/edge_cache_utility.rbCache purging
app/services/cloudflare_rules_service.rbRules sync
app/services/cloudflare_stream_api.rbVideo streaming
lib/turnstile.rbCAPTCHA verification
lib/middleware/cloudflare_geo_simulator.rbGeo testing
config/initializers/cloudflare.rbStream/Image token config
config/initializers/cloudflare_geo_simulator.rbGeo middleware setup
lib/tasks/cloudflare_rules.rakeRules sync rake tasks
lib/tasks/edge_cache.rakeCache 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