StackPractices
intermediate By Mathias Paulenko

CDN Edge Caching: Cloudflare, CloudFront, Fastly

Configure edge caching rules on Cloudflare, AWS CloudFront, and Fastly: TTL policies, cache keys, purge and stale-while-revalidate.

Overview

Your app serves users on three continents from one origin in us-east-1, and everyone outside the US waits 300+ ms per asset. A CDN puts cached copies on edge nodes near each user, so a page’s static files stop crossing the Atlantic on every request. This recipe configures the three providers you’re most likely to meet: Cloudflare’s cache rules, CloudFront cache policies via Terraform, and Fastly VCL — plus the purge patterns that keep cached content honest.

When to Use

Use this resource when:

  • Static assets (JS, CSS, fonts, images) are served to a geographically distributed audience
  • The origin burns CPU re-rendering identical responses for anonymous traffic
  • API responses are cacheable and benefit from s-maxage edge caching
  • You need cacheable downloads close to users to cut egress costs

Skip edge caching when:

  • Your audience is concentrated in one region near the origin: the extra hop buys nothing
  • Responses are per-user and not shareable (bank balances, session-specific pages): use Cache-Control: private and skip the CDN for those paths
  • You’re on a free tier with heavy traffic; bandwidth savings exist, but paid CDN features (custom cache keys, tag purging) may cost more than origin egress

Prerequisites

  • A CDN account: Cloudflare zone, AWS account for CloudFront, or a Fastly service
  • An API token scoped to cache rules (Cloudflare) or IAM permissions for CloudFront
  • Optional: Terraform 1.5+ for the CloudFront example; AWS CLI for invalidations
  • A browser or curl -I to verify response headers (Age, CF-Cache-Status, X-Cache)

Companion code: the cdn-edge-caching companion repo has the Terraform module, the cache-rules JSON, the VCL file, and the purge scripts ready to adapt.

Solution

Cloudflare: cache rules via the Ruleset API

Page Rules were deprecated in favor of cache rules on the Ruleset Engine. One rulesets call can create several rules at once:

curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/rulesets" \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "static-asset-caching",
    "kind": "zone",
    "phase": "http_request_cache_settings",
    "rules": [{
      "expression": "(http.request.uri.path.extension in {\"css\" \"js\" \"png\" \"jpg\" \"woff2\"})",
      "action": "set_cache_settings",
      "action_parameters": {
        "edge_ttl": { "mode": "override_origin", "default": 2592000 },
        "browser_ttl": { "mode": "override_origin", "default": 86400 }
      },
      "enabled": true
    }]
  }'

edge_ttl controls how long Cloudflare keeps the object; browser_ttl sets what it tells the browser. Both override_origin here because hashed asset URLs make a 30-day edge TTL safe, because a new deploy ships new URLs.

AWS CloudFront: Terraform cache policy

resource "aws_cloudfront_distribution" "cdn" {
  enabled = true

  origin {
    domain_name = aws_s3_bucket.static.bucket_regional_domain_name
    origin_id   = "S3Origin"
  }

  default_cache_behavior {
    target_origin_id       = "S3Origin"
    viewer_protocol_policy = "redirect-to-https"
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    compress               = true
    cache_policy_id        = aws_cloudfront_cache_policy.static.id
  }
}

resource "aws_cloudfront_cache_policy" "static" {
  name        = "static-assets"
  min_ttl     = 1
  default_ttl = 86400
  max_ttl     = 31536000

  parameters_in_cache_key {
    headers_config      { header_behavior = "none" }
    cookies_config      { cookie_behavior = "none" }
    query_strings_config { query_string_behavior = "none" }
  }
}

The parameters_in_cache_key block is the important part: keeping headers, cookies, and query strings out of the cache key is what makes the distribution actually cache things. Every parameter added to the key multiplies the number of cache entries per asset.

Fastly: VCL

sub vcl_recv {
  # Tag hashed static assets so vcl_fetch can treat them as immutable
  if (req.url.ext ~ "^(css|js|png|jpg|woff2)$") {
    set req.http.X-Static = "true";
  }
}

sub vcl_fetch {
  if (req.http.X-Static == "true") {
    set beresp.ttl = 365d;
    set beresp.http.Cache-Control = "public, max-age=31536000, immutable";
    set beresp.http.Surrogate-Key = "static";
  }
}

Surrogate-Key groups objects for purging: PURGE /service/<id>/purge/static flushes everything tagged static without touching the rest of the cache.

Explanation

Every CDN boils down to the same flow:

flowchart diagram: User

A cache hit serves the response from the PoP without touching your origin — that’s where the latency win and the origin offload come from. A cache miss fetches from the origin, stores the response per the cache policy, and serves it. The TTL decides how long the edge trusts that copy before revalidating.

The cache key is where hit ratios are won or lost. If your key includes ?utm_source=twitter, every marketing link becomes a separate entry and your hit ratio craters; the CloudFront policy above strips query strings precisely for that reason. If you do need varying responses, add only the specific parameter (?v=) to the key rather than accepting all of them.

For live content, Cache-Control splits into two TTLs: max-age for the browser and s-maxage for the CDN. Cache-Control: max-age=0, s-maxage=60 tells browsers “always revalidate” while letting the edge hold the response for a minute — long enough to absorb a traffic spike, short enough that staleness stays invisible. stale-while-revalidate goes further — serve the stale copy instantly while the edge refreshes it in the background.

Deeper cache-control mechanics live in HTTP Cache-Control headers, and purge/tag strategies are covered in CDN cache invalidation.

Advanced Usage

Purging instead of waiting

When content must change before its TTL expires:

# CloudFront: invalidate by path
aws cloudfront create-invalidation \
  --distribution-id E1EXAMPLE \
  --paths "/assets/*"

# Cloudflare: purge by URL
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"files": ["https://example.com/app.js"]}'

# Fastly: purge by surrogate key
curl -X POST "https://api.fastly.com/service/$SERVICE_ID/purge/static" \
  -H "Fastly-Key: $FASTLY_API_TOKEN"

Fastly’s surrogate-key purge is instant and scoped; Cloudflare’s tag purging works the same way but requires an Enterprise plan; on other tiers you purge by URL or by host. CloudFront invalidations propagate across all edge nodes in a few minutes and cost money after the free 1000 paths/month, which is why versioned filenames remain cheaper than invalidating.

Serving stale when the origin is down

stale-while-revalidate and stale-if-error let the edge serve an expired copy instead of erroring:

Cache-Control: max-age=60, s-maxage=300, stale-while-revalidate=86400, stale-if-error=604800

With that header, an origin outage degrades to “users see content up to a week old” instead of 500s: a free availability boost for static-ish content.

Variants

ProviderConfig surfaceCache key controlPurge model
CloudflareDashboard, Ruleset API, TerraformCache rules, custom cache keysURL, tag (Enterprise), host
AWS CloudFrontConsole, Terraform, CDKCache policies + origin request policiesPath-based invalidations
FastlyVCL, API, TerraformFull control via VCLInstant purge by key/URL/surrogate
AkamaiControl Center, PAPIProperty Manager rulesFast Purge API, cache tags

What Works

  1. Version asset filenames (app.v2.js, style.a1b2c3.css) so the URL itself is the cache key, so new deploys never fight old cache entries
  2. Keep the cache key minimal: headers, cookies, and query strings in the key fragment your cache and kill hit ratio
  3. max-age=0, s-maxage=N for content that’s safe at the edge but not in the browser
  4. stale-while-revalidate so users never wait on a refresh, and stale-if-error for origin outages
  5. Track the hit ratio (Cloudflare’s CF-Cache-Status, CloudFront’s X-Cache, Fastly’s Fastly-Debug) and treat drops as misconfigurations, not noise

Common Mistakes

  1. Caching responses that carry Set-Cookie: a session cookie served to the wrong user is a data leak, not a performance win
  2. Full-cache purges on every deploy: they flush warm edges globally; versioned filenames and tag-scoped purges avoid it
  3. Ignoring query strings in the cache key (?utm_source=... creating thousands of duplicate entries)
  4. Setting long max-age on HTML that references versioned assets; the HTML is the mutable pointer, keep its TTL short
  5. Assuming the CDN caches POST: only GET/HEAD are cacheable on default policies

Further Reading

Frequently Asked Questions

How do I cache API responses at the edge?

Give them s-maxage so the CDN caches while browsers don't (Cache-Control: max-age=0, s-maxage=60), and make sure the cache key includes whatever varies the response — usually the path plus specific query params. Responses with Set-Cookie or Authorization are bypassed on default policies, which is usually what you want.

What's the difference between purging and invalidation?

Same idea, different verbs: both mark cached objects unusable so the next request refetches. Fastly calls it purge (instant), CloudFront calls it invalidation (propagates in minutes, billed per path). If the URL changed (versioned filenames) you need neither.

Is edge caching worth it for API-only backends?

Yes when responses are shared and cacheable: product catalogs, public configs, aggregated stats. It's useless for per-user responses, which should carry Cache-Control: private. GraphQL is harder than REST here because everything is a POST to one endpoint; you'd need edge logic to normalize queries into cache keys.

How long should my TTLs be?

As long as the URL stays valid. Hashed filenames (app.a1b2c3.js) justify immutable + 1-year TTLs because a content change produces a new URL. HTML and live data should be short — seconds to a minute at the edge — because you can't invalidate what the browser holds.

Can I cache authenticated or personalized content?

Not by default, and that's protective. If you genuinely need it, include the auth dimension in the cache key (e.g., Vary: Authorization, or an edge worker that varies on a session-derived key) so different users never share an entry. Misconfigured, this is a data-leak vector.