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-maxageedge 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: privateand 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 -Ito 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:
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
| Provider | Config surface | Cache key control | Purge model |
|---|---|---|---|
| Cloudflare | Dashboard, Ruleset API, Terraform | Cache rules, custom cache keys | URL, tag (Enterprise), host |
| AWS CloudFront | Console, Terraform, CDK | Cache policies + origin request policies | Path-based invalidations |
| Fastly | VCL, API, Terraform | Full control via VCL | Instant purge by key/URL/surrogate |
| Akamai | Control Center, PAPI | Property Manager rules | Fast Purge API, cache tags |
What Works
- 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 - Keep the cache key minimal: headers, cookies, and query strings in the key fragment your cache and kill hit ratio
max-age=0, s-maxage=Nfor content that’s safe at the edge but not in the browserstale-while-revalidateso users never wait on a refresh, andstale-if-errorfor origin outages- Track the hit ratio (Cloudflare’s
CF-Cache-Status, CloudFront’sX-Cache, Fastly’sFastly-Debug) and treat drops as misconfigurations, not noise
Common Mistakes
- Caching responses that carry
Set-Cookie: a session cookie served to the wrong user is a data leak, not a performance win - Full-cache purges on every deploy: they flush warm edges globally; versioned filenames and tag-scoped purges avoid it
- Ignoring query strings in the cache key (
?utm_source=...creating thousands of duplicate entries) - Setting long
max-ageon HTML that references versioned assets; the HTML is the mutable pointer, keep its TTL short - Assuming the CDN caches POST: only GET/HEAD are cacheable on default policies
Further Reading
- Cloudflare cache rules documentation: Ruleset API, expressions, and TTL modes
- AWS CloudFront cache policies: managed vs custom policies and cache key composition
- Fastly caching guide: VCL lifecycle, surrogate keys, and shielding
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.
Related Resources
CDN Cache Invalidation Strategies and Patterns
Implement CDN cache invalidation using purge APIs, surrogate keys, tag-based invalidation, and versioned URLs to keep content fresh
RecipeHTTP Cache-Control Headers for APIs and Static Assets
Set Cache-Control, ETag, and Last-Modified headers to control browser and CDN caching for API responses and static assets
RecipeEnable Brotli Compression in Nginx for Static Assets
Configure Brotli compression in Nginx to shrink JavaScript, CSS, and HTML assets with better ratios than Gzip for faster page loads.
RecipeCache HTTP Responses with Nginx Reverse Proxy
Configure Nginx as a caching reverse proxy to cache upstream HTTP responses with TTL zones, cache keys, and conditional purging.
RecipeImplement Cache Invalidation Strategies
How to keep caches consistent with databases using TTL, write-through, write-behind, and event-driven invalidation patterns.
GuideComplete Guide to CDN Caching Strategy
Design CDN caching for web applications and APIs. Covers edge caching, cache keys, cache headers, invalidation strategies, surrogate keys, and multi-CDN setups for global performance.