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.purge_by_tags("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-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)

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.

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):

  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.

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:

  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:

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

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

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:

  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

Cache Not Purging

  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

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

Related Documentation