Cloudflare Infrastructure
This document covers our Cloudflare integration for the Heatwave project.
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)
Zones
| Environment | Domain | Zone ID | Plan |
|---|---|---|---|
| Production | warmlyyours.com |
cd991c21281a518afa7a0822dca3d434 |
Business |
| Staging | warmlyyours.ws |
d39acaed475782c4901d4a8e5908c1cb |
Pro |
| Development | warmlyyours.me |
4a387e118ce5ea4917eb8dc724f3e3a9 |
Free |
API Configuration
Account ID
79b7f58cf035093b5ad11747df30369a
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
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
Edge Cache Utility
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.("post", "product-123")
# Purge everything (use sparingly!)
Cache::EdgeCacheUtility.instance.purge_everything(:production)
Rake Task:
mise exec -- rake edge_cache:purge_everything
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)
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
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)
Workers
Cloudflare Workers run custom JavaScript at the edge, before requests hit the origin or cache.
Location: cloudflare-worker/
Active Workers
| Worker | Config | Purpose |
|---|---|---|
www-edge |
wrangler-www-edge.toml |
Locale redirects + R2 webpack assets + Asset versioning + Turbo debug headers |
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)
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)
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::ReviewsControllerand
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.
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 of
assets-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
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
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:
cd cloudflare-worker
npx wrangler tail -c wrangler-www-edge.toml --env production --format json
Worker Deployment
Prerequisites:
# Wrangler is available via npx (no global install needed)
cd cloudflare-worker
# Authenticate (first time only)
npx wrangler login
Deploy:
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:
# List deployments
npx wrangler deployments list -c wrangler-www-edge.toml
# View live logs
npx wrangler tail -c wrangler-www-edge.toml --env production
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 production
Delete Worker Entirely:
npx wrangler delete -c wrangler-www-edge.toml --env production
Testing Workers
Test Asset Versioning (GET/HEAD):
# 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:
# 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:
# 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
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
| 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
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)
Classic firewall rules using expressions. Being phased out in favor of WAF Custom Rules.
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.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
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)
See README_CLOUDFLARED.md for tunnel setup instructions.
Production Tunnel: heatwave-app-production
Staging Tunnel: heatwave-app-staging
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
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/dev
Turnstile (CAPTCHA)
Location: lib/turnstile.rb
Used for bot protection on forms.
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
Authentication Errors
# Check token validity
mise exec -- rake cloudflare:rules:check_permissions
If 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
- 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
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
# Check tunnel status
cloudflared tunnel list
cloudflared tunnel info <tunnel-name>
# Check service status
systemctl status cloudflared
journalctl -u cloudflared -f