Cloudflare Workers
Cloudflare Workers is a fully supported runtime, chosen with one flag (--runtime cloudflare), and the one KV and image transformations exist for. @plumix/runtime-cloudflare holds the adapter that bridges the Worker to Plumix, plus one small factory per platform service, and each factory goes in the config slot named after the job it does.
Overview
Section titled “Overview”The runtime is a slot like any other. runtime: cloudflare() tells Plumix which platform it is running on, and the rest of the package fills the slots beside it.
| Factory | Slot | What it wires up |
|---|---|---|
cloudflare() |
runtime |
The Workers fetch and scheduled handlers |
d1({ binding }) |
database |
A D1 database |
r2({ binding }) |
storage |
An R2 bucket |
kv({ binding }) |
kv |
A Workers KV namespace |
images({ zone }) |
imageDelivery |
Cloudflare Image Transformations |
Three more exports do not fill a slot. cloudflareDeployOrigin derives the passkey origin for whichever host a Workers Builds build is served on, which Passkey origins across deploys below covers. cfAccess replaces the request authenticator with a Cloudflare Access JWT check, and cfAccessLogoutUrl builds the Access sign-out URL that check needs on the way back out.
Only runtime, database and auth are required. A site with no storage slot has no uploads, and a site with no cdn slot renders every public page live.
cloudflare() also tells the CLI where its commands live. That is how plumix dev, plumix build, plumix deploy, plumix types and plumix migrate apply exist at all. They come from the runtime rather than from core, and swapping the runtime swaps them.
Install
Section titled “Install”pnpm create plumix-app installs this for you when you pick the Cloudflare runtime. Adding it to an existing project takes three packages, the runtime plus wrangler and the ambient Workers types.
pnpm add @plumix/runtime-cloudflarepnpm add -D wrangler @cloudflare/workers-typesnpm install @plumix/runtime-cloudflarenpm install -D wrangler @cloudflare/workers-typesyarn add @plumix/runtime-cloudflareyarn add -D wrangler @cloudflare/workers-typesQuickstart
Section titled “Quickstart”-
Point the config at the runtime. In
plumix.config.ts:import { auth, plumix } from "plumix";import { cloudflare, d1 } from "@plumix/runtime-cloudflare";import { recipes } from "./plugins/recipes";import { theme } from "./theme";export default plumix({runtime: cloudflare(),database: d1({ binding: "DB", session: "auto" }),auth: auth({passkey: {rpName: "Recipes",...(process.env.PLUMIX_DEV? { rpId: "localhost", origin: "http://localhost:5173" }: {rpId: "your-account.workers.dev",origin: "https://recipes.your-account.workers.dev",allowedOrigins: ["https://*.your-account.workers.dev"],}),},}),plugins: [recipes],theme,}); -
Declare the same names in
wrangler.jsonc.{"name": "recipes","main": ".plumix/worker.ts","compatibility_date": "2026-04-01","compatibility_flags": ["nodejs_compat"],"d1_databases": [{"binding": "DB","database_name": "recipes","database_id": "paste-the-id-from-wrangler-d1-create","migrations_dir": "drizzle",},],"assets": {"directory": ".plumix/public","binding": "ASSETS","not_found_handling": "none",},"triggers": {"crons": ["0 3 * * *", "*/5 * * * *"],},} -
Run it.
Terminal window plumix devThe dev server runs the real Worker under
workerdthrough Vite, against a local D1 file. What you exercise locally is the same handler the deploy runs.
D1 and the database slot
Section titled “D1 and the database slot”d1({ binding }) wraps the D1 binding in Drizzle, with casing: "snake_case" so the generated migrations and the runtime queries agree on column names.
The session option decides how reads are routed, and it takes three values.
"disabled" is the default. Every read and write goes to the primary database, so a read always sees the write that preceded it.
"auto" turns on the D1 Sessions API. Writes go to the primary. A read from a request carrying a Plumix session cookie resumes the caller’s prior bookmark when one is present and well formed, so a signed-in editor never reads behind their own write. Anonymous reads go to the nearest replica.
"primary-first" uses the Sessions API but defaults to the primary, so you get the bookmark machinery without nearest-replica routing.
Under "auto" and "primary-first", Plumix carries the bookmark in a cookie named __plumix_d1_bookmark, and writes it only for authenticated requests. Rename it with bookmarkCookie if that collides with something. If the deployed workerd predates the Sessions API, the adapter falls back to the plain binding rather than failing.
R2 and the storage slot
Section titled “R2 and the storage slot”r2({ binding }) gives the site server-side reads, writes, listing and deletes against a bucket. Two capabilities beyond that are conditional.
Public URLs need a base. Pass publicUrlBase: "https://media.recipes.example" or set the <BINDING>_PUBLIC_URL_BASE env key, which for the conventional MEDIA binding is MEDIA_PUBLIC_URL_BASE. With neither, the storage slot returns null for an object’s public URL, and a plugin that needs one serves the file through the Worker instead. That default is deliberate. A bucket with no custom domain would otherwise hand out storage keys that nobody can guess but that nothing re-checks against the entry’s status.
Presigned uploads need S3 credentials, because an R2 binding cannot mint a presigned URL. Supply them as s3: { bucket, accountId, accessKeyId, secretAccessKey }, or as an (env) => ... resolver, or leave the option off and set four env keys: CF_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY and <BINDING>_BUCKET. All four have to be present. With a partial set, presigned uploads stay switched off rather than failing at upload time. Presigned PUT URLs expire after 60 seconds unless the caller asks for longer.
Images
Section titled “Images”images() builds Cloudflare Image Transformations URLs of the form https://<zone>/cdn-cgi/image/width=800,format=webp/<source>. The zone is part of the URL, not an ambient setting, which is why the adapter no-ops without one. It resizes and re-encodes what is already in your bucket, so it pairs with r2 rather than replacing it, and the zone has to have Image Transformations enabled in the dashboard first.
zone is a bare hostname with no scheme and no path. Omit it and the adapter reads MEDIA_PUBLIC_URL_BASE at request time, which pairs automatically only with a bucket bound as MEDIA. With no zone resolved either way, image delivery is absent and <Image> renders the source URL untouched. A source already on the zone has its host stripped and a site-relative source loses its leading slash, so the transform addresses the object by its path either way.
kv({ binding }) binds a Workers KV namespace onto the request context as ctx.kv, with get, put, delete and list over string values. Core reads nothing from it. The slot is there for plugin handlers that want a cheap key/value store, and memoryKv() from plumix stands in for it in tests.
Workers KV rejects an expirationTtl under 60 seconds at write time, so treat that as the floor.
The CDN
Section titled “The CDN”The cdn slot comes from core rather than from this package, because nothing about it is Workers-specific: the same cloudflare() provider from plumix/cdn/cloudflare runs behind the zone from a container or a VM. CDN Caching is that page — the slot’s configuration, the cache rule a zone needs before it caches HTML, the tag vocabulary and what a publish purges. This section is what a Worker adds to it.
A Worker sits inside the colo, in front of its own zone’s cache, so the zone never holds the page the Worker hands back and correct headers on the way out are not enough to cache it. The provider therefore also writes each render into the Workers Cache API, and that stored copy is what a later request is answered from. It is the one place a runtime changes what caching does, and it is discovered rather than configured: the provider looks for caches.default and uses it when it is there.
The stored copy differs from the one the visitor receives in two ways, both of them about the copy nobody else holds. Its Set-Cookie is stripped, which the Cache API requires and which keeps one visitor’s cookie out of an entry everyone reads. And it is the one place private and no-store are overwritten with the page TTL rather than obeyed: a segment variant sends them so that no intermediary reuses its render, while the segment-keyed edge copy beside it is shared on purpose. The visitor’s own copy always keeps them. Only a GET fills an entry; a HEAD render is decorated and served without storing one.
That store is also what lets a Workers deploy cache a page for an audience segment at all. The site owns the key, so two visitors in the same segment share one entry and distinct segments never collide. Behind a CDN the site does not run on, there is no key to write and a non-anonymous segment renders live — the trade-off CDN Caching states beside the host comparison.
A workers.dev deploy has no zone and so no zoneId to resolve, which is why caching stays off there and switches on when you attach a domain and set both secrets.
Workers Caching
Section titled “Workers Caching”Cloudflare’s Workers Caching — "cache": { "enabled": true } in wrangler.jsonc — is a different mechanism from the Cache API store above, and it is off by default. It puts a cache in front of the Worker entrypoint that reads the Cache-Control and Cache-Tag headers the provider already writes, so turning it on caches your pages and every cacheable plugin route beside them, sitemaps and social cards included.
The provider cannot clear any of it. Workers Caching is purged through its own Worker-scoped binding, and the zone purge behind your Cache Purge token does not reach it. A publish would clear the Cache API entry and leave this one serving the old page until it expires on its own — and the cdn telemetry fact will not show you that it happened, because a hit never reaches the Worker to record one.
Leave the flag off, on the purge argument above. It also caches more of the entrypoint than the cdn slot governs: every response the Worker returns passes through it, not only the ones the provider decorates. The platform’s own endpoints are covered — RPC, the REST API and MCP declare no-store themselves, and the admin shell private, no-cache — so what is left undeclared is whatever a plugin’s own route returns without saying. The scaffolded wrangler.jsonc does not set it.
Scheduled tasks and cron triggers
Section titled “Scheduled tasks and cron triggers”A scheduled task declares the schedule it wants to run on, and the runtime is responsible for firing it. On Workers that responsibility is triggers.crons in wrangler.jsonc, and the two halves are matched as strings.
Cloudflare passes the schedule it fired as event.cron, and Plumix runs only the tasks whose declared cron byte-matches it. Equivalent is not enough: a task declaring 0 3 * * * never runs under a trigger written 0 3 * * ?, and neither Cloudflare nor Plumix reports the mismatch. The trigger fires, no task claims it, and the invocation does nothing.
So every distinct schedule a task declares has to appear in triggers.crons, spelled the same way. The scaffolded wrangler.jsonc already covers the two schedules core registers, which is why the quickstart’s block above carries 0 3 * * * and */5 * * * *. A plugin may register more, and one whose schedule you configure yourself moves the trigger with it.
A trigger with no task behind it is harmless: the invocation runs, matches nothing, and ends. A task with no trigger behind it never runs at all, which is the failure worth checking for after adding a plugin or changing a schedule.
When a firing fails
Section titled “When a firing fails”A failing task is caught so its siblings still run, and the failure is logged with its error — visible in wrangler tail and Workers Logs. The invocation itself is then marked failed, so the Cron Trigger Past Events table, Workers analytics and anything alerting on them agree with the logs. One failed task is enough, which is the rule plumix cron run already applies on a Node deploy.
Whether Cloudflare replays the firing turns on whether any task succeeded, because that is what decides if a replay duplicates work. Cloudflare re-runs the whole handler, not the tasks that failed, and nothing on Workers deduplicates that — the run guard that does is a Node deploy’s.
- No task succeeded. The run never started, or every task it reached threw, so a replay repeats nothing. Plumix leaves Cloudflare’s retry in place, which is the transient case a retry is for.
- Some tasks succeeded and some failed. A replay would run the successful ones a second time, so Plumix declines it and the firing waits for its next scheduled minute.
Work handed to waitUntil is unaffected either way: the CDN purges and telemetry a successful task queued still settle, even though the invocation ends in an error.
There is no way to exempt a task from any of this — a flaky task reddens every firing it fails in. If that is the situation you are in, catch inside the task and decide there whether the failure is worth reporting.
Passkey origins across deploys
Section titled “Passkey origins across deploys”A passkey is bound to a hostname. Workers Builds gives production recipes.your-account.workers.dev and each branch <branch>-recipes.your-account.workers.dev, so a credential anchored to one of those hosts means re-enrolling on every preview. Anchoring it to the registrable domain they share is what avoids that.
cloudflareDeployOrigin hands you that span. Spread it into passkey and it returns rpId: "your-account.workers.dev", the origin of whichever host this build is served on, and allowedOrigins: ["https://*.your-account.workers.dev"].
import { auth } from "plumix";
import { cloudflareDeployOrigin } from "@plumix/runtime-cloudflare";
export const recipeAuth = auth({ passkey: { rpName: "Recipes", ...cloudflareDeployOrigin({ workerName: "recipes", accountSubdomain: "your-account", localOrigin: "http://localhost:5173", }), },});It reads that host out of WORKERS_CI and WORKERS_CI_BRANCH, which only Cloudflare Workers Builds sets. The Plumix Vite plugin substitutes both into the bundle as literals, taking them from the machine running plumix build, because a Worker’s own process.env holds bindings and never those names. So the helper resolves a real origin on a build that runs on Workers Builds, and returns rpId: "localhost" with your localOrigin — which defaults to http://localhost:8787 — on a build that does not. A plumix build on your own machine is the second case, whether you go on to deploy it or not.
A locally built deploy whose passkeys have to work therefore states the origin in the config itself. Anchor rpId to your-account.workers.dev with the wildcard https://*.your-account.workers.dev in allowedOrigins, so one passkey still spans production and every preview branch, and keep the localhost pair behind process.env.PLUMIX_DEV, which the plugin substitutes as a literal too, "1" under plumix dev and empty under plumix build.
import { auth } from "plumix";
export const recipeAuth = auth({ passkey: { rpName: "Recipes", ...(process.env.PLUMIX_DEV ? { rpId: "localhost", origin: "http://localhost:5173" } : { rpId: "your-account.workers.dev", origin: "https://recipes.your-account.workers.dev", allowedOrigins: ["https://*.your-account.workers.dev"], }), },});rpId takes a plain string rather than an (env) => string resolver, so it has to be the same in every environment, and the config validator checks that every literal allowedOrigins entry has a host that is rpId or a subdomain of it. The wildcard above passes because its base host is rpId exactly.
Production on a custom domain drops allowedOrigins and writes rpId: "recipes.example" with origin: "https://recipes.example". The helper’s productionOrigin: "https://recipes.example" says the same thing — Workers Builds does not expose the custom domain, so it has to be declared — and leaves previews on their workers.dev host. It applies to the default branch only, which the helper takes as main unless you pass defaultBranch. Get that wrong and production is treated as a preview: it lands back on workers.dev, under an rpId the custom domain cannot match, and no wildcard covers the gap. A custom domain and workers.dev are different registrable domains, so no single passkey covers both, and previews then need magic-link or Cloudflare Access to sign in.
Cloudflare Access
Section titled “Cloudflare Access”cfAccess replaces the request authenticator with a check on the Cf-Access-Jwt-Assertion header that Cloudflare Access sets once a request has passed the application’s policy.
import { auth } from "plumix";
import { cfAccess } from "@plumix/runtime-cloudflare";
export const recipeAuth = auth({ passkey: { rpName: "Recipes", rpId: "recipes.example", origin: "https://recipes.example", }, authenticator: cfAccess({ teamDomain: "your-team.cloudflareaccess.com", audience: "your-cf-access-aud", defaultRole: "editor", bootstrapAllowed: true, }), bootstrapVia: "first-method-wins",});teamDomain has to end in .cloudflareaccess.com, and the adapter rejects anything else at boot rather than failing every request later. audience is the application’s AUD tag from the Access dashboard, checked against the JWT’s aud claim so a token minted for a different application on the same team is refused. defaultRole is required, because the Access JWT carries an email and an identity provider but no Plumix role.
The two bootstrap options in that example do different jobs. bootstrapAllowed belongs to cfAccess and defaults to false, and while it is false an Access user arriving at an empty users table is refused with registration_closed, the authenticator returns no principal, and nobody gets in through Access at all. bootstrapVia belongs to core and gates the built-in flows instead. Its default "passkey" refuses magic-link and OAuth signup while the users table is empty, so the first admin has to enrol through the passkey rail. "first-method-wins" lets the first account through any of those flows take the admin role. Setting only bootstrapVia is the trap, because it leaves Access itself shut. The example sets both, so the first admin can arrive through Access or through a built-in flow.
The built-in sign-in routes stay mounted underneath. Firewall /_plumix/auth/* at the edge if you want Access to be the only way in, or leave them live to keep passkeys as a fallback.
Signing out takes one more piece. Clearing the Plumix session cookie leaves the Access JWT on the next request, and the reader is signed straight back in. cfAccessLogoutUrl("your-team.cloudflareaccess.com") returns https://your-team.cloudflareaccess.com/cdn-cgi/access/logout, the URL that clears the Access session. Point your sign-out control at it.
Commands the runtime adds
Section titled “Commands the runtime adds”plumix dev boots Vite with @cloudflare/vite-plugin and takes --port and --inspector-port. plumix build builds the client environment first and the worker second, because the worker bakes the client’s asset manifest into its bundle. plumix deploy and plumix types forward to wrangler deploy and wrangler types with your arguments appended. plumix migrate apply forwards to wrangler d1 migrations apply, reading the database name out of your wrangler config when you do not name one.
Related
Section titled “Related”Bindings and Environment covers the naming contract these factories rest on and what happens when a name is wrong. Secrets covers the (env) => value resolver form, which is how R2’s S3 credentials reach the adapter without being written into the config file. The passkey story here is one half of Passkeys, which covers enrolment and the bootstrap rail. Runtime Adapters is the contract this package implements, for anyone porting Plumix to a second platform. CDN Caching is the cdn slot in full, and the page to read for a deploy of the same site that is not a Worker.
Next steps
Section titled “Next steps”Go to Bindings and Environment for the wrangler side of every binding string above, then Secrets for the values that never appear in either file. Configuration lists every slot these factories plug into, including the ones no Cloudflare adapter fills.