CDN Caching skill for Codex
High hit ratios without leaking anyone's page. Codex probes live responses before changing anything, using a bundled script that reads CDN status headers and flags cookies, private directives and wide Vary. It then sets TTLs per content class, trims cache keys to what really changes the response, adds stale-while-revalidate and stale-if-error, and replaces purge-everything habits with cache tags emitted on publish.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- cdn-caching Installs to ~/.agents/skills/cdn-caching/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install cdn-caching The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project cdn-caching Installs to .agents/skills/cdn-caching/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/cdn-caching.zipunzip -o cdn-caching.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) cdn-caching PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $cdn-caching, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Configures shared CDN caching on Cloudflare, CloudFront, Fastly, Vercel and Netlify - cache keys and query string or cookie normalization, s-maxage and CDN-Cache-Control TTLs, stale-while-revalidate and stale-if-error, Vary, cache tags and surrogate keys, purge strategy, and debugging HIT, MISS and BYPASS with a bundled probe script. Use when the cache hit ratio is low, users see stale pages after a deploy, the origin melts under traffic, personalized pages leak between users, or you are designing purge-on-publish for a CMS.
Use it when
- The CDN hit ratio is low or the origin takes too much traffic
- Content stays stale after deploys or CMS publishes
- You are setting Cache-Control, CDN-Cache-Control or cache tags on responses
- There is a risk of personalized responses being cached for everyone
Use something else when
- The question is about browser caching, ETags and conditional requests: use http-caching
- You need CloudFront distribution setup, OAC or functions: use aws-cloudfront
How to check the result
Run scripts/cache_probe.py per content class to see MISS then HIT with the intended TTL, confirm cookie-bearing requests bypass, and test that a tag purge updates the page.
The mistake it prevents
Leaving Set-Cookie or Vary: Cookie on public responses, which either disables the cache or serves one user's response to others.
A CDN setup is right when static assets are cached for a year under hashed names, HTML and API responses have short shared TTLs with stale-while-revalidate, the cache key contains exactly what changes the response and nothing else, personalized responses are never stored in a shared cache, and content changes reach users through targeted purges rather than “purge everything”.
Browser-side semantics (ETags, conditional requests, no-cache versus no-store) are
covered by http-caching; this skill is about the shared edge cache.
Inspect first
- Read
AGENTS.md, then find where headers are set: framework config (next.config, route handlers),vercel.json,netlify.toml/_headers, Cloudflare Cache Rules, CloudFront cache policies, Fastly VCL or Compute code, origin server (nginx, Caddy). - Probe real responses before changing anything:
python3 scripts/cache_probe.py https://www.example.com/ --count 3
python3 scripts/cache_probe.py https://www.example.com/assets/app.3f9c1.js --expect-hit
The probe needs network. It prints caching headers per request, recognises vendor status
headers (cf-cache-status, x-cache, x-vercel-cache, cache-status) and flags
Set-Cookie, private, wide Vary and missing directives. On a python.org macOS build
that cannot verify TLS, run it with SSL_CERT_FILE=/etc/ssl/cert.pem.
- Collect the hit ratio from the CDN analytics if available, split by content type.
TTL policy by content type
| Content | Header from origin | Notes |
|---|---|---|
Hashed assets (app.3f9c1.js, fonts, images with content hash) | public, max-age=31536000, immutable | Never purge; new deploys use new names |
Unhashed static files (/logo.png, /robots.txt) | public, max-age=3600, s-maxage=86400 | Purge by URL when changed |
| Public HTML | public, max-age=0, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400 | Browser revalidates, edge serves fast |
| Public API GET (catalog, prices) | public, s-maxage=60, stale-while-revalidate=300 | Add cache tags for purge-on-change |
| Logged-in HTML, account APIs | private, no-store | Never in a shared cache |
| Errors (5xx) | Short or none | Do not let a transient 500 stick for an hour |
To give the CDN a different TTL than browsers without touching Cache-Control, use a
targeted header the CDN strips before forwarding: CDN-Cache-Control (RFC 9213, honoured
by Cloudflare, Vercel, Netlify and others), or Surrogate-Control on Fastly. Provider
specific variants (Cloudflare-CDN-Cache-Control, Vercel-CDN-Cache-Control,
Netlify-CDN-Cache-Control) target one layer when several CDNs are stacked.
// Route handler: browsers revalidate, the edge keeps it 5 minutes and serves stale for a day.
return new Response(html, {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "public, max-age=0, must-revalidate",
"CDN-Cache-Control": "public, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400",
"Cache-Tag": "page,post-123,author-9",
},
});
Cache keys
The key decides which requests share an object. Too wide means leaks; too narrow means misses.
| Input | Default rule |
|---|---|
| Query string | Include only parameters the origin reads; drop utm_*, fbclid, gclid; sort the rest |
| Cookies | Exclude from the key; bypass cache when a session cookie is present |
Accept-Encoding | Handled by the CDN’s compression normalization; do not add manually |
Accept-Language / country | Prefer distinct URLs (/de/) over varying on the header |
| Device type | Prefer responsive HTML; if you must, key on a normalized device class, not User-Agent |
Authorization | Requests with it should bypass the shared cache unless the response is explicitly public |
Vary: Cookie, Vary: User-Agent or Vary: * effectively disables caching. Remove them
or replace them with normalized key components in the CDN configuration.
Provider notes:
- Cloudflare does not cache HTML by default; it needs a Cache Rule marking it eligible, with edge TTL taken from origin headers. Bypass when the session cookie is present.
- CloudFront keys come from the cache policy (headers, cookies, query strings); the origin request policy controls what is forwarded without affecting the key. Forwarding all headers kills the hit ratio.
- Fastly keys on URL and Host by default; extend with
Varyor VCLreq.hash. - Vercel and Netlify honour
s-maxage/CDN-Cache-Controlfrom functions and invalidate the CDN on each deploy.
Purging
| Strategy | When | How |
|---|---|---|
| Versioned URLs | Assets | Content hash in file name; no purge |
| Purge by URL | Few known pages | Cloudflare files, CloudFront invalidation paths, Fastly URL purge |
| Purge by tag or surrogate key | CMS publish, product update touching many pages | Cache-Tag (Cloudflare), Surrogate-Key (Fastly), Edge-Cache-Tag (Akamai) |
| Soft purge (mark stale) | High traffic, origin fragile | Fastly Fastly-Soft-Purge: 1; serves stale while refetching |
| Purge everything | Emergencies only | Causes an origin thundering herd; confirm with the user first |
# Cloudflare: purge by tag (token with Cache Purge permission; needs network)
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" \
--data '{"tags":["post-123"]}'
# CloudFront: invalidate paths (wildcards allowed at the end)
aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/blog/post-123" "/blog/"
Wire purges into the publish pipeline: on save in the CMS, emit the tags affected (post, author, listing pages) and purge them after the database commit, not before.
Stale handling
stale-while-revalidatelets the edge serve a stale copy while it refetches in the background; users never wait on the origin for popular pages.stale-if-errorkeeps serving the last good copy when the origin returns 5xx or times out. Combine with origin shielding (tiered cache, Origin Shield) so one edge, not hundreds, refetches.- Support differs per CDN and per plan; confirm with the probe that a stale response is
served (status
STALE,UPDATING, or a growingAgebeyonds-maxage).
Verification
- Probe shows a MISS then HIT for each content class with the intended TTL.
- A request with a session cookie or
Authorizationreturns BYPASS or a private response, never a shared HIT. - Changing a tagged item and purging its tag updates the page within seconds.
-
utm_*variations of a URL hit the same cache object. - Hit ratio tracked after the change; target above 90% for static assets.
Gotchas
Set-Cookieon a cacheable response either disables caching or, worse on some setups, caches one user’s cookie for everyone. Strip cookies from public responses.- Caching redirects (301) at the edge for a year makes a wrong redirect very hard to undo. Use short TTLs on redirects until they are proven.
- A 404 cached for hours after publishing a page looks like a deploy bug. Give 404 a short TTL.
- Two CDNs stacked (Cloudflare in front of Vercel or CloudFront) each cache separately; purge both layers or use per-layer headers.
- Purging does not clear browser caches; long browser
max-ageon unhashed URLs cannot be recalled.
Output
## CDN cache review: <site>
| Path class | Current headers | Status (probe) | Proposed headers | Key changes |
- Cache key changes: <query params dropped, cookies bypass rule>
- Purge plan: <tags emitted on publish, API call, who triggers it>
- Risks: <personalized pages, cookies, stacked CDNs>
- Verified: <probe output summary before and after> What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "CDN Caching"
short_description: "High hit ratios without leaking anyone's page"
default_prompt: "$cdn-caching Our Cloudflare cache hit ratio is 20 percent, find out why and fix the headers" scripts/cache_probe.py
#!/usr/bin/env python3
"""Probe a URL through its CDN and explain what the cache did.
Requests the URL several times, prints the caching-relevant headers from each
response, recognises common CDN status headers (Cloudflare, CloudFront, Fastly,
Vercel, Netlify, Akamai, RFC 9211 Cache-Status) and flags configurations that
prevent shared caching.
Usage:
python3 cache_probe.py https://example.com/assets/app.js
python3 cache_probe.py https://example.com/ --count 3 --expect-hit
python3 cache_probe.py https://example.com/api/x -H "Accept-Language: de"
Needs network access. Exit codes: 0 ok, 1 --expect-hit set but no hit seen
(or blocking problems found), 2 request failed.
"""
from __future__ import annotations
import argparse
import re
import sys
import time
import urllib.error
import urllib.request
SHOW = [
"cache-control", "cdn-cache-control", "surrogate-control", "cloudflare-cdn-cache-control",
"vercel-cdn-cache-control", "netlify-cdn-cache-control", "age", "expires", "etag",
"last-modified", "vary", "cache-status", "cf-cache-status", "x-cache", "x-cache-hits",
"x-vercel-cache", "x-served-by", "x-amz-cf-pop", "cache-tag", "surrogate-key",
]
HIT = re.compile(r"\bhit\b|^hit|revalidated|stale", re.I)
def fetch(url: str, headers: dict[str, str], timeout: float):
req = urllib.request.Request(url, headers={"User-Agent": "cache-probe/1.0", **headers})
start = time.monotonic()
try:
resp = urllib.request.urlopen(req, timeout=timeout)
except urllib.error.HTTPError as err: # 4xx/5xx still carry cache headers
resp = err
body = resp.read()
ms = (time.monotonic() - start) * 1000
return resp.status, {k.lower(): v for k, v in resp.headers.items()}, resp.headers.get_all("set-cookie") or [], len(body), ms
def cache_state(h: dict[str, str]) -> str:
for key in ("cf-cache-status", "x-vercel-cache", "x-cache", "cache-status"):
if key in h:
return f"{key}={h[key]}"
if "age" in h:
return f"age={h['age']} (no vendor status header)"
return "unknown (no CDN status header)"
def is_hit(h: dict[str, str]) -> bool:
for key in ("cf-cache-status", "x-vercel-cache", "x-cache", "cache-status"):
if key in h and HIT.search(h[key]):
return True
try:
return int(h.get("age", "0")) > 0
except ValueError:
return False
def problems(h: dict[str, str], cookies: list[str]) -> list[str]:
out = []
cc = ",".join(h.get(k, "") for k in ("cache-control", "cdn-cache-control", "surrogate-control")).lower()
if not cc.strip(","):
out.append("no Cache-Control: the CDN falls back to its defaults (often: do not cache HTML)")
if "no-store" in cc or "private" in cc:
out.append("private/no-store: shared caches must not store this response")
if "max-age=0" in cc and "s-maxage" not in cc and "stale-while-revalidate" not in cc:
out.append("max-age=0 without s-maxage: caches without an edge TTL override revalidate every request")
if cookies:
out.append(f"Set-Cookie present ({len(cookies)}): many CDNs bypass the cache for these by default")
vary = h.get("vary", "").lower()
if "*" in vary or "cookie" in vary or "user-agent" in vary:
out.append(f"Vary: {h.get('vary')}: fragments the cache (near-zero hit ratio)")
return out
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("url")
p.add_argument("--count", type=int, default=2, help="number of requests (default 2)")
p.add_argument("--delay", type=float, default=1.0, help="seconds between requests")
p.add_argument("--timeout", type=float, default=15.0)
p.add_argument("-H", "--header", action="append", default=[], help="extra request header 'Name: value'")
p.add_argument("--expect-hit", action="store_true", help="exit 1 unless a later request is a cache hit")
a = p.parse_args()
extra = {}
for item in a.header:
if ":" not in item:
print(f"error: bad header {item!r}", file=sys.stderr)
return 2
k, v = item.split(":", 1)
extra[k.strip()] = v.strip()
hits_after_first = False
last_h: dict[str, str] = {}
last_cookies: list[str] = []
for i in range(1, a.count + 1):
try:
status, h, cookies, size, ms = fetch(a.url, extra, a.timeout)
except (urllib.error.URLError, TimeoutError, OSError) as exc:
print(f"error: request {i} failed: {exc}", file=sys.stderr)
return 2
print(f"--- request {i}: HTTP {status}, {size} bytes, {ms:.0f} ms, cache: {cache_state(h)}")
for key in SHOW:
if key in h:
print(f" {key}: {h[key]}")
if cookies:
print(f" set-cookie: {len(cookies)} cookie(s)")
if i > 1 and is_hit(h):
hits_after_first = True
last_h, last_cookies = h, cookies
if i < a.count:
time.sleep(a.delay)
issues = problems(last_h, last_cookies)
print("\nfindings:")
for msg in issues or ["no blocking cache headers found"]:
print(f" - {msg}")
print(f" - later requests served from cache: {'yes' if hits_after_first else 'no'}")
if a.expect_hit and not hits_after_first:
print("\nFAIL: expected a cache hit after the first request")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.