Spillway CMSStructured content like Drupal. Page building like Puck. Setup like Ghost. AI that drafts, people who approve.
Menu

Caching and CDN purge

How a published page gets from the renderer to a visitor, what Spillway caches on the way, which tags it files pages under, how a CDN in front is kept in step, and what to set where. The admin side is Site settings → Cache (getting-started.md); the endpoints are in api.md, Site; the internals in architecture.md.

The path of a request

  1. A visitor asks the CDN (if there is one) for /about. The CDN serves its copy while it is fresh (s-maxage), serves a stale copy and refreshes in the background for a while longer (stale-while-revalidate), and otherwise asks Spillway.
  2. Spillway looks the path up in its page cache, keyed by site and path. A hit is returned at once with X-Spillway-Cache: HIT.
  3. On a miss the page is rendered from the published revision, stored in the page cache together with its tags, and returned with X-Spillway-Cache: MISS.
  4. When content changes, the change invalidates tags: the page cache drops every entry carrying one of them, and, with a CDN connected, the same tags (or the URLs they stand for) are sent to the CDN.

The page cache is in process by default (500 entries) or shared through Redis with REDIS_URL (install.md). Query-dependent pages (exposed filters, calendars, term listings, search) are cached per path and declared query for a minute. Previews, the admin and the API are never cached.

Tags

Every rendered page carries the tags of what went into it:

TagSet byInvalidated when
entity:<id>every page, and every page that links or lists the entitythe entity is saved while published, published, unpublished, archived, restored or deleted
type:<key>, list:<type>every page of the type; listings of the typean entity of the type changes; the content type is saved
site:<id>every page of the siteany entity of the site changes; the site settings or theme are saved, or a brand draft that changes the theme or the identity is published
tgroup:<id>every translation of a group (hreflang alternates)a translation changes
menu:<key>pages rendering the menu (the shell renders main and footer)the menu is saved, renamed or deleted
shared:<key>pages embedding the shared blockthe shared block is saved or deleted
collection:<id>, collection:<key>Collection blocks and feedsthe collection is saved; an item of its type is published or unpublished
form:<id>pages with the formthe form is saved or deleted
style:<block>:<key>pages rendering a block in that block style, or storing that key (style:Hero:dark-band, style:events/agenda:compact)a published brand draft adds, removes or changes the style, or moves the block's default to or from it (theming.md)
brand-guidethe public style guide at /brand (with theme)a brand draft is published; the voice is saved (theming.md)
media:<id>pages rendering the media itemthe item's focal point changes; a DAM webhook bumps its version; the file is copied local
term:<id>term pages and pages filtered by the termthe term or its vocabulary is saved
audience:<key>, experiment:<key>pages with Personalize or Experiment islandsthe audience or experiment is saved
themethe theme stylesheet (/api/theme/tokens.css) and every pagethe site settings or theme are saved, or a brand draft is published (Brand previews with ?__brand=draft are never cached)
searchthe search pagethe index is rebuilt
registry, registry:<name>registry blocks, and on a registry host the public registry JSONa package is published, reviewed, yanked, deprecated or gets an advisory

The tags are visible on every public response:

Cache-Tag: entity:0192...,type:page,site:default,menu:main,menu:footer
Surrogate-Key: entity:0192... type:page site:default menu:main menu:footer
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300
Vary: Accept-Encoding
X-Spillway-Cache: HIT

Cache-Tag is what Cloudflare (Enterprise) and most tag-aware caches read; Surrogate-Key is the same list separated by spaces, the header Fastly and Varnish read. max-age=0 keeps browsers honest. Cached pages and public render responses keep a brotli and a gzip copy beside the HTML, made by the first hit that asks for each and dropped with the entry, so a hit never compresses; each copy has its own ETag. Vary is only Accept-Encoding: a public page is the same for every visitor, and varying on Cookie or User-Agent would split the CDN's cache per person. Media files and hashed admin assets are immutable for a year; their URL changes when the file does.

Cache settings

Site settings → Cache holds, per site:

  • s-maxage and stale-while-revalidate (seconds): the Cache-Control of public pages, feeds, the theme stylesheet and the public render API. Defaults 60 and 300.
  • Provider and its credentials, the zone or service id, and the purge options below.
  • Purge everything when the theme or the site settings change: a theme save purges the whole CDN instead of the site: and theme tags (assets and untagged responses go too).

How to set them depends on what sits in front of Spillway. Read the rows as degrade steps: the further down the table, the less the CDN helps and the shorter the headers should be.

Setups-maxagestale-while-revalidateWhy
Tag purge (Cloudflare Enterprise, Fastly)long: 3600 or more86400every change purges exactly the affected pages within seconds, so the edge can hold pages for hours
URL purge (Cloudflare on other plans, Bunny, the generic webhook)medium: 300 to 9003600changes still purge, but a site-wide change costs one call per 30 URLs or a full purge, so let the edge refresh pages on its own too
A CDN without a provider connectedshort: 60300nothing purges the edge; the defaults bound how stale a page can be
No CDNanyanythe headers are read by nothing; the page cache does the work

Without BASE_URL or a site base URL (or a primary domain), URL providers cannot build absolute URLs: the test endpoint says so, and a purge that needs URLs is marked failed with that hint.

The purge hook

With a provider connected, every tag invalidation reaches the CDN:

  1. The page cache's invalidateTags is wrapped: the tags are collected for two seconds (a publish evicts three to five tags; a kit install evicts hundreds), so one change is one purge.
  2. At the end of the window the tags are resolved per site with a provider. site:<id> names its site, entity:<id> is looked up, every other tag (a menu, the theme, a shared block, the registry) concerns every site that has a provider.
  3. For each site a purge is composed to fit the provider:
    • a tag provider receives the tags;
    • a URL provider receives URLs: the entity's path with its locale prefix, every published translation's path (the locale variants), the locale roots when the entity is the home page or one of its translations, the pages this process had cached under the tags (so an unpublished or deleted page is still purged), and, for any site-wide tag (site:, type:, list:, menu:, theme, shared:, media:, term:, ...), every published URL of the site. Past 500 URLs the purge becomes everything;
    • the generic webhook receives both.
  4. One row per site goes into the cdn_purge queue and is sent right away, off the request. A failure is retried after 30 seconds, 2 minutes and 10 minutes by the same ticker that delivers notifications (or spillway run-scheduled), then marked failed. Every settled purge writes a cdn.purge audit entry (actor: who asked, or none when automatic) and stays in the Last purges log under Site settings → Cache (the last 200 per site).

Nothing in this chain blocks the request that made the change: the collection is synchronous and cheap, the resolution and the calls run later.

Multi-instance hosts: every instance purges what it invalidated itself, and Redis invalidations received from another instance are not purged again, so one change is one purge whichever instance took the write. The "pages this process had cached" part of a URL purge is per instance; a site-wide tag covers the rest.

Purging by hand

  • Site settings → Cache → Purge: tags, paths or URLs separated by spaces, or Purge everything.
  • POST /api/site/cdn/purge { tags?, urls?, everything? } with site.settings (paths are allowed in urls; an empty request purges everything).
  • spillway cdn purge [--all] [--site KEY] [--tag T]... [--url U]... from the terminal.

Providers

Cloudflare

Create an API token with the Zone → Cache Purge permission on the zone (My Profile → API Tokens), enter it with the zone id (Overview page of the zone), then Test connection: it reports the zone name and plan.

Cloudflare does not cache HTML by default. Add a cache rule (Caching → Cache Rules) for the site's hostname that sets the cache eligibility to Eligible for cache and lets the origin's Cache-Control set the edge TTL ("Use cache-control header if present"), or the older page rule Cache Level: Cache Everything with Edge Cache TTL: respect existing headers. Leave /admin*, /api/* and /_preview/* out of the rule (Spillway sends no-store on those, but a rule that bypasses them costs nothing). With that in place the s-maxage and stale-while-revalidate from the Cache settings govern the edge.

Two ways to purge:

  • By Cache-Tag (tick "Enterprise zone"): one call per change, exact. Needs the Enterprise plan; the test endpoint warns when the zone is on another plan.
  • By URL (the default): files in batches of 30 per call. Precise for a single page; a site-wide change purges the published URLs, or everything past 500. Cloudflare rate limits purge calls per zone; a 429 is retried after the Retry-After it sends, up to three times per batch, and the queue retries the whole purge later if that fails.

Fastly

An API token with the purge_select scope (and purge_all for "Purge everything") and the service id. Pages are purged by surrogate key: the Surrogate-Key header Spillway sends is exactly the list Fastly indexes, so a purge names the same tags the page cache evicts. "Purge everything" calls purge_all; a manual purge with URLs only goes through Fastly's per-URL purge. Fastly honors Surrogate-Control over Cache-Control when present; Spillway sends Cache-Control only, which Fastly reads as well.

Bunny

The account API key (Account → API) and, for "Purge everything", the pull zone id. Bunny purges by URL, one call per URL (five at a time), so prefer a longer s-maxage and let site-wide changes become "everything" rather than hundreds of calls.

Generic webhook

For any other edge (Varnish behind a small purge service, KeyCDN, CloudFront through a Lambda, a cache of your own): a purge URL and an optional signing secret. Spillway posts

{ "site": "default", "at": "2026-11-06T10:00:00.000Z", "tags": ["entity:0192...", "type:page", "site:default"], "urls": ["https://www.example.org/about"], "everything": false }

with Content-Type: application/json, X-Spillway-Event: cdn.purge and, when a secret is set, X-Spillway-Signature: sha256=<hex HMAC-SHA256 of the body> (the same scheme as the notification webhooks, verified in adapters.md). Answer 2xx; anything else is retried. Test connection posts { "test": true } with empty lists. The receiver gets both the tags and the URLs, so it can purge by key where the edge supports it and by URL where it does not.

The registry host

On a registry host (REGISTRY_HOST=1) the public registry JSON is tagged too: index.json, the package list, stats, advisories and publisher profiles carry Cache-Tag: registry; a package's detail, manifest and readme carry registry,registry:<name>. Downloads and files are immutable and untagged. Every registry mutation (publish, review decision, yank, deprecation, advisory, publisher change) evicts the same tags from the page cache and sends them to the provider, with the JSON URLs for URL providers, so a published version shows at the edge at once.

Headless front ends

The public render API (/api/public/*) carries the same Cache-Tag, Surrogate-Key and Cache-Control as the HTML, so a CDN between an Astro or Next.js front end and Spillway is purged the same way. The front end's own cache is told through the entity.published, entity.unpublished and entity.deleted webhooks, which carry cacheTags and publicPath (adapters.md).

Spillway 0.1.0 · Edit this page on GitHub

Was this page helpful?