Cloudflare Infrastructure
This document covers our Cloudflare integration for the Heatwave project.
Overview
Section titled “Overview”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 |
API Configuration
Section titled “API Configuration”Account ID
Section titled “Account ID”79b7f58cf035093b5ad11747df30369aAPI Tokens
Section titled “API Tokens”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
Required Token Permissions
Section titled “Required Token Permissions”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)
Services
Section titled “Services”Edge Cache Utility
Section titled “Edge Cache Utility”Location: app/services/cache/edge_cache_utility.rb
Provides cache purging functionality:
# Purge specific URLsCache::EdgeCacheUtility.instance.purge_url("https://www.warmlyyours.com/page")
# Purge by cache tagsCache::EdgeCacheUtility.instance.purge_by_tags("post", "product-123")
# Purge everything (use sparingly!)Cache::EdgeCacheUtility.instance.purge_everything(:production)Rake Task:
mise exec -- rake edge_cache:purge_everythingZone 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.
Cloudflare Stream API
Section titled “Cloudflare Stream API”Location: app/services/cloudflare_stream_api.rb
Manages video hosting on Cloudflare Stream:
api = CloudflareStreamApi.instanceapi.get_video(cloudflare_uid)api.enable_mp4_downloads(cloudflare_uid)Workers
Section titled “Workers”Cloudflare Workers run custom JavaScript at the edge, before requests hit the origin or cache.
Location: cloudflare-worker/
Active Workers
Section titled “Active Workers”| Worker | Config | Purpose |
|---|---|---|
www-edge | wrangler-www-edge.toml | Locale redirects + R2 webpack assets + Asset versioning + Turbo debug headers |
WWW Edge Worker
Section titled “WWW Edge Worker”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-Frameheader)
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.htmlheader). 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: Acceptdebug 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::ReviewsControllerandWww::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.
Feature 3: Asset Versioning (ETag/304)
Section titled “Feature 3: Asset Versioning (ETag/304)”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):
- Worker
fetch()es the response, then computes a hash ofassets-manifest.json(cached 60s at edge). - Checks the response’s
Cache-Control— if not cacheable, returns it untouched and stops here. - Otherwise attaches
ETag: "manifest-<hash>"andCache-Control: no-cache. - On revisit, if the browser’s
If-None-Matchmatches the current manifest hash, returns304 Not Modified. - 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.
Route Configuration
Section titled “Route Configuration”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.
Observability
Section titled “Observability”Logging is enabled for debugging:
[observability.logs]enabled = truehead_sampling_rate = 1persist = trueinvocation_logs = trueView logs via MCP:
# Use cloudflare-observability MCP server to query logs# Filter by: $metadata.service = "www-edge-production"View logs via CLI:
cd cloudflare-workernpx wrangler tail -c wrangler-www-edge.toml --env production --format jsonWorker Deployment
Section titled “Worker Deployment”Prerequisites:
# Wrangler is available via npx (no global install needed)cd cloudflare-worker
# Authenticate (first time only)npx wrangler loginDeploy:
cd cloudflare-worker
# Deploy to staging firstnpx wrangler deploy -c wrangler-www-edge.toml --env staging
# Then productionnpx wrangler deploy -c wrangler-www-edge.toml --env productionCheck Worker Status:
# List deploymentsnpx wrangler deployments list -c wrangler-www-edge.toml
# View live logsnpx wrangler tail -c wrangler-www-edge.toml --env productionWorker Removal / Rollback
Section titled “Worker Removal / Rollback”Disable via Dashboard:
- Go to Cloudflare Dashboard
- Navigate to: Workers & Pages →
www-edge-production→ Settings → Routes - Delete or disable the routes
Rollback to Previous Version:
npx wrangler rollback -c wrangler-www-edge.toml --env productionDelete Worker Entirely:
npx wrangler delete -c wrangler-www-edge.toml --env productionTesting Workers
Section titled “Testing Workers”Test Asset Versioning (GET/HEAD):
# Check ETag and X-Asset-Version headers on HTMLcurl -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 ETagcurl -skI -H "If-None-Match: \"manifest-2126c2d7285e\"" https://www.warmlyyours.com/en-US# Expected: HTTP/2 304, x-cache-status: REVALIDATEDTest Turbo Cache Key:
# 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-sectionVerify Non-HTML Pass-Through:
# JSON/API requests should NOT have worker-added headerscurl -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 headersDashboard Location
Section titled “Dashboard Location”- Workers Overview: https://dash.cloudflare.com/79b7f58cf035093b5ad11747df30369a/workers
- Worker Analytics: Workers & Pages →
www-edge-production→ Analytics - Worker Routes: warmlyyours.com zone → Workers Routes
- Observability Logs: Workers & Pages →
www-edge-production→ Logs
Worker Files Reference
Section titled “Worker Files Reference”| 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 |
Rule Types
Section titled “Rule Types”Rulesets (Modern)
Section titled “Rulesets (Modern)”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 |
Firewall Rules (Legacy)
Section titled “Firewall Rules (Legacy)”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 betweeninfra/terraform/cloudflare-zone-production/and…-staging/in a PR — adapting hostnames (warmlyyours.com→warmlyyours.ws) and plan differences (matchesregex 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).
MCP Integration
Section titled “MCP Integration”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/
Tunnels (Cloudflared)
Section titled “Tunnels (Cloudflared)”See README_CLOUDFLARED.md for tunnel setup instructions.
Production Tunnel: heatwave-app-production
Staging Tunnel: heatwave-app-staging
Cloudflare Access (Zero Trust)
Section titled “Cloudflare Access (Zero Trust)”Staging environment is protected by Cloudflare Access:
- Requires authentication to access
*.warmlyyours.ws - Configured in Cloudflare Zero Trust dashboard
Geo Location
Section titled “Geo Location”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
# Test with different geos in development:TRACK_VISITOR=y TRACK_VISITOR_PROFILE=CA-QC bin/devTurnstile (CAPTCHA)
Section titled “Turnstile (CAPTCHA)”Location: lib/turnstile.rb
Used for bot protection on forms.
Files Reference
Section titled “Files Reference”| 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 |
Troubleshooting
Section titled “Troubleshooting”Authentication Errors
Section titled “Authentication Errors”# Check token validitymise exec -- rake cloudflare:rules:check_permissionsIf permissions fail:
- Go to https://dash.cloudflare.com/79b7f58cf035093b5ad11747df30369a/api-tokens
- Edit the token and add missing permissions
- Update the token in 1Password and credentials
Cache Not Purging
Section titled “Cache Not Purging”- Verify edge cache is enabled:
Cache::EdgeCacheUtility.edge_cache_enabled? - Check zone ID is correct in
zone_map - Test with a single URL purge first
Rules Sync Failures
Section titled “Rules Sync Failures”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
Tunnel Issues
Section titled “Tunnel Issues”# Check tunnel statuscloudflared tunnel listcloudflared tunnel info <tunnel-name>
# Check service statussystemctl status cloudflaredjournalctl -u cloudflared -f