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.

  • Cloud Platforms
  • $cdn-caching
  • 158 lines
  • 3 files
  • ~1,950 tokens
  • MIT
  • Updated
  • Editor's pick

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- cdn-caching

Installs to ~/.agents/skills/cdn-caching/ for every project on this machine.

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.

~/.agents/skills/cdn-caching/SKILL.md

--- name: cdn-caching description: Configures shared CDN caching on Cloudflare, CloudFront, Fastly, Vercel and Netlify - cache keys and query string or cookie normalization, s-maxage… license: MIT ---

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

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

  1. Collect the hit ratio from the CDN analytics if available, split by content type.

TTL policy by content type

ContentHeader from originNotes
Hashed assets (app.3f9c1.js, fonts, images with content hash)public, max-age=31536000, immutableNever purge; new deploys use new names
Unhashed static files (/logo.png, /robots.txt)public, max-age=3600, s-maxage=86400Purge by URL when changed
Public HTMLpublic, max-age=0, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400Browser revalidates, edge serves fast
Public API GET (catalog, prices)public, s-maxage=60, stale-while-revalidate=300Add cache tags for purge-on-change
Logged-in HTML, account APIsprivate, no-storeNever in a shared cache
Errors (5xx)Short or noneDo 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.

InputDefault rule
Query stringInclude only parameters the origin reads; drop utm_*, fbclid, gclid; sort the rest
CookiesExclude from the key; bypass cache when a session cookie is present
Accept-EncodingHandled by the CDN’s compression normalization; do not add manually
Accept-Language / countryPrefer distinct URLs (/de/) over varying on the header
Device typePrefer responsive HTML; if you must, key on a normalized device class, not User-Agent
AuthorizationRequests 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 Vary or VCL req.hash.
  • Vercel and Netlify honour s-maxage / CDN-Cache-Control from functions and invalidate the CDN on each deploy.

Purging

StrategyWhenHow
Versioned URLsAssetsContent hash in file name; no purge
Purge by URLFew known pagesCloudflare files, CloudFront invalidation paths, Fastly URL purge
Purge by tag or surrogate keyCMS publish, product update touching many pagesCache-Tag (Cloudflare), Surrogate-Key (Fastly), Edge-Cache-Tag (Akamai)
Soft purge (mark stale)High traffic, origin fragileFastly Fastly-Soft-Purge: 1; serves stale while refetching
Purge everythingEmergencies onlyCauses 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-revalidate lets the edge serve a stale copy while it refetches in the background; users never wait on the origin for popular pages.
  • stale-if-error keeps 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 growing Age beyond s-maxage).

Verification

  • Probe shows a MISS then HIT for each content class with the intended TTL.
  • A request with a session cookie or Authorization returns 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-Cookie on 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-age on 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.yamlgenerated from this page

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.py135 lines, 5.4 KB
#!/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.

esc