CDN Caching
The cdn slot says one thing: there is a shared cache in front of this site, here is who runs it, how fresh a page may be, and how to tell it to drop something. It is the one line in a config that does not change when the site moves hosts — runtime, database and storage all do.
Overview
Section titled “Overview”A CDN provider does two jobs, and neither of them needs a particular runtime. On the way out it decorates a shared-cacheable public response with the freshness and cache tags the vendor reads. When content changes it purges those tags through the vendor’s API, which is an HTTP call. A Worker, a container and a VM all make that call the same way.
Where the runtime also offers an origin-side response store the provider uses that too — Cloudflare Workers does, through the Cache API. A Worker runs in front of its own zone’s cache, so the zone never holds what the Worker hands back and the stored copy is what a later request is answered from. Which mechanism a host adds is the provider’s business and never appears in configuration.
Plumix ships one provider, cloudflare() from plumix/cdn/cloudflare. It lives in core rather than in a runtime package and carries no dependencies — a fetch call and some header writes — so a container deploy does not pull a Workers toolchain into its image to get caching. A Worker imports it from @plumix/runtime-cloudflare/cdn instead: the same provider with the same options, plus the purge of Workers Caching that only Worker code can reach.
import type { CdnProvider } from "plumix";import { cloudflare as cdn } from "plumix/cdn/cloudflare";
export const cdnSlot: CdnProvider = cdn({ ttl: 3600, staleWhileRevalidate: 86400, zoneId: (env) => env.CF_ZONE_ID, purgeToken: (env) => env.CF_CACHE_PURGE_TOKEN,});
declare module "plumix" { interface PlumixEnv { readonly CF_ZONE_ID: string; readonly CF_CACHE_PURGE_TOKEN: string; }}Every provider exports its bare vendor name, and importing it under the cdn alias is what makes swapping one a single-word edit. ttl becomes s-maxage in seconds. staleWhileRevalidate is how long a colo may keep serving an expired copy while it fetches a fresh one behind the visitor’s back.
zoneId and purgeToken are required, and the provider stays entirely inert when either resolves to nothing on this deploy: nothing is cached and every page renders live. A cache the site cannot purge would serve a stale recipe until its freshness ran out, so refusing to fill one is the safer failure. It is silent rather than logged, because “not deployed yet” is the ordinary local state; in development the debug bar’s slot row is where an unconfigured CDN shows.
Quickstart
Section titled “Quickstart”This is the whole path for a site that is not a Worker — a container, a droplet, a VM — sitting behind a Cloudflare zone. A Workers deploy takes steps 1 to 4, importing the provider from @plumix/runtime-cloudflare/cdn, and skips the rest: it is already on the zone, its pages reach the cache through Workers Caching and the Cache API rather than through a cache rule, and Cloudflare Workers covers what it sees instead.
-
Add the slot. The provider above, as
cdn:inplumix.config.tsbesideruntimeanddatabase. Nothing in it names your host. -
Find the zone id. It is on the zone’s overview page in the Cloudflare dashboard, under API.
-
Mint a purge token. A user API token scoped to that one zone with the
Cache Purgepermission, and nothing else. -
Put both in the environment. They reach the config through the
(env) => …resolvers above, which readprocess.envon Node — a.envfile in development, the container’s own environment in production. Secrets covers the resolver form.Terminal window CF_ZONE_ID=<the zone id>CF_CACHE_PURGE_TOKEN=<the token> -
Proxy the origin through the zone. The site’s DNS record has to be orange-clouded, or requests never reach a Cloudflare colo and no header the origin writes is read by anything.
-
Add a cache rule for HTML. Cloudflare does not cache HTML documents by default, whatever the origin asks for. Without this rule the site emits correct headers and caches nothing. The cache rule below has the settings.
-
Verify a page caches. Two requests to the same public URL, the second answered from the zone:
Terminal window curl -sI https://recipes.example/ | grep -i 'cf-cache-status\|cache-control\|cache-tag'The first is
cf-cache-status: MISS, the secondHIT.cache-controlcarries thes-maxageyou configured andcache-tagnames what a purge will reach. That header is the zone’s own verdict, so it is the signal for this shape of deploy and not for a Worker, whose pages are held in the Cache API a layer below it; there thecdntelemetry fact is what records a hit. -
Publish something and watch it clear. Edit a published entry in the admin and publish it again. Its permalink and every archive listing it go back to
MISSon the next request.
The cache rule
Section titled “The cache rule”Cloudflare honours origin freshness for static file extensions on its own. An HTML document is not one of them: it needs a rule marking it eligible for cache, and a rule telling the edge to take its TTL from the origin rather than from a fixed number.
Under Caching → Cache Rules, create a rule matching the traffic you want cached — Hostname equals recipes.example is the usual shape — and set:
- Cache eligibility to Eligible for cache.
- Edge TTL to Use cache-control header if present, bypass cache if not.
- Browser TTL to Respect origin TTL.
Edge TTL is the load-bearing half. Left on a fixed number it overrides the s-maxage the provider writes, and your ttl becomes decoration; set to respect the origin, the config file stays the single place freshness is decided.
What each host caches
Section titled “What each host caches”The slot’s configuration is identical across the three rows below. What differs is what the deploy has to cache with.
| Deploy | How a copy is held | Segments other than private |
|---|---|---|
| Worker behind its own zone | Workers Caching for the anonymous page, and the colo cache, written through the Cache API | Cached, keyed per segment |
| Container or VM behind a Cloudflare zone | The zone’s cache, filled from the headers | Rendered live |
Any host with no cdn slot, or with a credential missing |
Nothing | Rendered live |
The difference is where a hit is answered. A Worker still runs on every request; a hit means it found the page in the Cache API and returned it without rendering. Behind a CDN the site does not run on, a hit never reaches the site at all, so every request the origin does see is a miss by definition — which is why the telemetry cdn fact records whether a store was in play, rather than letting a storeless deploy read as a permanently failing cache.
A page the site stores itself is scoped to the build that rendered it. Every plumix build gets its own id, and the store key carries it, so a deploy starts with a cold store rather than serving the previous build’s pages and the asset URLs they point at, and during a gradual rollout each version reads only its own pages. The previous build’s entries are never read again and expire by their ttl. A copy held by a CDN in front, filled from the headers, is keyed by the CDN on the URL alone.
The third column is the trade-off worth weighing before you choose a host. An access policy resolves each visitor into an audience segment, and two visitors in the same segment can share one cached page only if something can key the cache on that segment. The Cache API can, because the site owns it and writes the key itself. A CDN alone cannot, unless the vendor can vary on a named cookie — so behind one, a request in any segment other than anonymous bypasses the cache and renders live rather than risk one audience receiving another’s page. Anonymous pages, which on most sites are nearly all of them, are unaffected and cache either way. One segment never caches on any host: private is the escape hatch a policy reaches for when a render is genuinely per-visitor, and it bypasses everywhere, which is also where an ephemeral grant lands.
Which routes cache
Section titled “Which routes cache”Four built-in route kinds are cacheable: a single entry, an archive, a taxonomy archive and the front page. Search is deliberately excluded, because one entry per distinct query string would fill the cache for no gain. An archive type a plugin registered is the fifth kind, and it caches only when that registration passed cacheable: true, since core cannot work out what a plugin’s archive depends on. Pair that opt-in with the tags its resolver returns — usually the t:<type> of each type it draws from — or the archive caches under no tag and no publish can reach it. Author and date archives are the gap in the other direction: they are tagged like the front page, but they are not among the cacheable kinds, so they render live.
On a page or a registered public route carrying no access policy, a request bearing an Authorization header or a ?preview= token bypasses the cache entirely: either can make the render differ from the shared anonymous document. So does a request the site’s authenticator calls signed in — its own hasSession answer, not the presence of the standard cookie — to a registered public route. On a page, a signed-in request reads the anonymous copy without ever writing it. A copy stored by an anonymous visit is the same for everyone, so the reader is served it, leaving private, no-cache, and their islands load the session on the client. With nothing stored it renders live, leaves private, no-store, and stores nothing. A reader who gets the admin bar, or whose own locale differs from the request’s, always renders live. On a policied route the segment decides instead, and an authenticated member of a cacheable audience reads that audience’s entry rather than bypassing — so the two ephemeral grants, ?preview= and ?plumix.edit, are excluded by name there. A policy that grants anonymous to a request the authenticator calls signed in, or to one bearing an Authorization header or a ?preview= token, renders it private too. Either way a draft never enters a shared cache, which is the guarantee Statuses rests on: a render authorized for one request must not outlive it.
A shared segment holds one copy for every member, so a render that read the principal is never the one stored. Core tracks the reads during the render: ctx.user, ctx.tokenScopes and ctx.auth.can() on the context a block loader, a template’s document or a render.document handler receives, and useUser() in a component. Any of them makes the render personal: it is not written to the store, it leaves as private, no-store, and the telemetry cdn fact records personal: true. A page that shows the admin bar, or that resolves to the member’s own locale rather than the request’s, is personal from the start. The next member still reads whatever the segment already holds, so a page that reads no principal caches as before, and a page that does pays for the read with its caching rather than with another member’s data. There is nothing to declare: a block that greets the visitor by name is personal because it read the name. A page that wants the shared copy and a personal touch personalises on the client with useAuth().
A raw route a plugin mounted at /_plumix/<pluginId> is the sixth cacheable kind, and it takes the same opt-in: registerRoute({ cacheable: true }). Only an auth: "public" route may take it, since a stored response is served to everyone, and registering it on a gated route throws at boot. Take the opt-in only where the route answers every visitor with the same document, locale included: the entry is keyed off the request URL with the cookie dropped, so a signed-in visitor reads the shared copy rather than bypassing it the way a page render does, and a Vary the handler sets is not a key axis. For the same reason the handler never sees who is asking: ctx.user is null on a cacheable route, signed in or not, and anything that resolves the visitor below it, such as a procedure the handler calls, resolves nobody. The whole URL is the key, query string included, so any parameter a caller invents is another entry.
Reading the shared copy is one thing and filling it is another. A route response is stored only when nothing about it says it belongs to one visitor: the request carried no session, Authorization header or ?preview= token, and the response sets no cookie and declares itself neither private nor no-store. A handler that gates itself — on a bearer token Plumix never sees, say — is registered auth: "public" like any other, and this is what keeps its answer out of the shared entry.
Freshness on such a route is the response’s to declare. A handler that sets its own Cache-Control keeps it — a content-addressed asset asking for max-age=31536000, immutable is stored with exactly that, and a content-addressed URL is the one kind immutable belongs on, since a purge reaches the CDN but never the browser or the scraper holding the URL. The slot’s ttl applies only to a response that declared none.
The platform’s own endpoints decide for themselves. Every answer from /_plumix/rpc/**, /_plumix/api/** and /_plumix/mcp declares no-store — the refusals included, since a 404 and a 405 are both heuristically cacheable and would otherwise be the answers an intermediary felt free to keep. no-store needs no private beside it; it already binds every cache, shared and private alike. Nothing on those surfaces carries a cache tag, so a shared copy could never be purged when the content behind it changes. The admin shell is the exception that proves the rule: it sends private, no-cache, which lets the visitor’s own browser hold a copy it revalidates while private keeps it out of every cache in between.
Each of those three branches is stamped as a whole, so a new exit inside one cannot escape it. Three responses are refused before any of them is reached and still declare nothing: the CSRF 403, the 500 from the dispatcher’s error boundary, and — on a subdirectory deploy only — the 404 for a path outside the base. The first two are not heuristically cacheable; the third is, though it answers Not Found to a URL that never entered the site.
RPC also answers anything but POST with a 405. oRPC reads a GET’s input from ?data=, and the session cookie rides a top-level navigation, so a signed-in visitor could otherwise be lured into putting their own JSON behind a URL anyone can request.
Cache tags
Section titled “Cache tags”A cached page is stored under tags, and a publish purges by tag rather than by URL. The content vocabulary is these four — coarse by design, since a page that lists content cannot enumerate what it will list next week. Every tag is lower-cased on the way in, because at least one target CDN matches tags case-insensitively.
A page is stored under the tags its route implies plus every tag its render declared. Core declares the entry types of every entry query it compiled through compileEntryQuery, the entries it embedded, the terms it rendered, as tm:<id>, the authors it resolved, as u:<id>, and the settings groups it read, as s:<group>; every render reads the site group, so every page carries s:site. A plugin gets the same tags by reading core’s content through these helpers, from a template dep’s loader, a component or an event or filter handler; it never names a core tag itself. For its own data, which core doesn’t model, a plugin calls ctx.cache.reads(key), or ctx.cache.reads() when the response read all of it; core keeps those tags in a namespace named after the plugin.
t:<type> — the entry type tag. It goes on every page that lists or embeds content of that type: its archives, the front page, term archives, and permalinks, which can render sibling content of their own. Any publish of that type reaches all of them.
e:<id> — the single entry tag. An entry’s own permalink carries it, and so does any page that resolved a reference to that entry while rendering: resolution contributes the tag as it materializes the entity, and the page stores under it. So editing one recipe clears its permalink and every page that embedded it.
tm:<id> — the term tag. Any page that rendered that term carries it: its archive and feed, an entry’s term chips, and a page that embedded it through a term field. So renaming a term, or saving only its meta, clears only the pages that showed it.
tx:<taxonomy> — the taxonomy list tag. A response that listed a taxonomy’s terms carries it, such as the SEO plugin’s sitemap of that taxonomy. A plugin that lists terms builds its query on taxonomyTermRows(ctx, taxonomy) from plumix/db, which declares the tag. So creating a term, or deleting one, clears every list of its taxonomy’s terms, whether or not the taxonomy lists entry types.
u:<id> — the user tag. Any page that resolved that user as an author carries it: a permalink’s byline, a listing’s entries, and an author archive and its feed. So editing one author’s profile or meta clears only the pages that showed them.
Purging
Section titled “Purging”Publishing an entry enqueues t:<type> and e:<id>; any write to a user, a meta-only save, a locale change and a delete included, enqueues their u:<id>, and a delete also enqueues the u:<id> of the user their entries went to; editing a term, a meta-only save included, enqueues its tm:<id>; creating or deleting a term enqueues its tm:<id> and its taxonomy’s tx:<taxonomy>; saving a settings group enqueues its s:<group>. A plugin’s own cacheable route is stored under what was declared while its handler ran, so it stays untagged unless the handler reads something core tags or calls ctx.cache.reads. A route that lists entries through compileEntryQuery is stored under their types’ tags, so a publish of one of those types clears it. Core purges only for writes made through it: a plugin writes entries, terms and settings with ctx.entries, ctx.terms and ctx.settings.write, never directly to core’s tables. When a plugin’s own data changes, ctx.cache.changed(key) purges every response that read that key or all of the plugin’s data, and ctx.cache.changed() purges everything the plugin declared. Neither reaches another plugin’s responses, even one that reads the same key. Call both from the plugin’s own route, template dep, RPC procedure or event handler; core reads which plugin is calling from there, and either call throws anywhere else.
The purge runs after the response through ctx.defer, so a zone that refuses one is logged and the editor’s publish still succeeds. Freshness is the backstop: anything a failed purge left behind expires on its own within ttl.
Purging everything
Section titled “Purging everything”purgeAll drops every response the CDN stores. Core calls it once after a sync has replaced the site’s data, because a sync changes rows under every tag at once. Cloudflare’s provider sends the zone a purge_everything call with the same purge token, and on a Worker with Workers Caching on it purges that of everything too.
Like purgeTags, it is optional: a vendor that can’t purge everything leaves it out rather than shipping a no-op. A sync then reports that the CDN can’t purge everything, and cached pages refresh as their ttl runs out.
Rotating the purge token
Section titled “Rotating the purge token”A deploy with working credentials fills the CDN. Redeploy without them — a rotated secret nobody carried over, an environment variable dropped in a config rewrite — and the provider goes inert on the next boot. New responses leave undecorated, publishing enqueues nothing, and no error appears anywhere, because the origin genuinely cannot see the entries the CDN already holds. Those pages then stay stale for the full freshness window, and a long ttl is a long time to be wrong.
Nothing in the code can defend this, so treat a token rotation as a two-step change: set the new token, confirm the deploy still purges, and revoke the old one after. Behind a zone that confirmation is step 7’s header — publish something and watch cf-cache-status fall back to MISS; on a Worker it is the cdn telemetry fact doing the same job. When a published change refuses to appear, that same check is the diagnosis: a page that survives a publish is a deploy whose credentials no longer resolve.
Providers without tag purge
Section titled “Providers without tag purge”A vendor that cannot invalidate by tag is a supported provider, not a broken one. The port makes purgeTags optional precisely so such a vendor is configurable, and optional rather than a no-op method so that no call site can believe a purge happened when none did.
The consequence is that freshness becomes the only control you have. Correctness comes from the TTL expiring, not from anything the publish does, so the recommended ttl for such a provider is minutes rather than the hours a purging provider can afford: pick the longest window you are willing to serve a stale page for, and accept that every edit takes up to that long to appear. A provider with an origin store and no tag purge is sharper still — an entry it holds is reachable only by expiry — and belongs on a short TTL for the same reason.
Related
Section titled “Related”Cloudflare Workers covers the Cache API store this page’s first table row gets, and the rest of the Workers adapter. Node.js is the container-shaped deploy the quickstart above is written for. Secrets covers the (env) => value resolvers the two credentials arrive through, and Configuration lists the cdn slot beside every other one a site config carries.
Runtime Adapters has the provider contract and describeCdnContract, the conformance suite a new provider is held to.
Next steps
Section titled “Next steps”Run the verification in step 7 against a real deploy before you rely on any of this — the cache rule is the step most sites miss, and its symptom is a site that looks configured and caches nothing. Then read Statuses for how drafts and preview links stay out of a shared cache.