Skip to content

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.

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 and plumix types exist at all, and how plumix migrate reaches D1. They come from the runtime rather than from core, and swapping the runtime swaps them.

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.

Terminal window
pnpm add @plumix/runtime-cloudflare
pnpm add -D wrangler @cloudflare/workers-types
  1. Point the config at the runtime. In plumix.config.ts:

    import { plumix } from "plumix";
    import { auth } from "plumix/auth";
    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,
    });
  2. 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",
    },
    ],
    "assets": {
    "directory": ".plumix/public",
    "binding": "ASSETS",
    "not_found_handling": "none",
    },
    "triggers": {
    "crons": ["0 3 * * *", "*/5 * * * *"],
    },
    }
  3. Run it.

    Terminal window
    plumix dev

    Before it starts, plumix dev applies every pending migration to a local D1 file, and the dev server then runs the real Worker under workerd through Vite, against that file. What you exercise locally is the same handler the deploy runs.

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. Sessions and API tokens are always checked against the primary, whatever the request’s method or bookmark.

"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({ 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. The browser then uploads to the bucket directly, so the bucket also needs CORS rules, which Media lists.

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/runtime stands in for it in tests.

Workers KV rejects an expirationTtl under 60 seconds at write time, so treat that as the floor.

Workers static assets serve the client build, and a request for one never reaches the Worker. Cloudflare serves them public, max-age=0, must-revalidate unless a _headers file says otherwise, so plumix build writes one at the root of the client output. It marks the content-hashed build output, /assets/* and /_plumix/admin/assets/*, public, max-age=31536000, immutable.

To add your own rules, put a _headers file in public/. The build writes Plumix’s blocks first and your file after them, as written. Cloudflare applies _headers rules in file order, every rule that matches a path adds its headers, and ! Header removes only what an earlier rule added. So to give some hashed files a different Cache-Control, detach the default in your file and set a new value:

public/_headers
/assets/fonts/*
! Cache-Control
Cache-Control: public, max-age=86400

Without the ! Cache-Control line, Cloudflare joins the two values into one header with conflicting directives.

The cdn slot’s provider is core’s cloudflare() from plumix/cdn/cloudflare, the one that runs behind the zone from a container or a VM. CDN Caching is that page — the slot’s configuration, the tag vocabulary and what a publish purges. A Worker imports it from this package instead, with the same options, because one cache in front of a Worker is purged only from Worker code:

import { cloudflare as cdn } from "@plumix/runtime-cloudflare/cdn";
export const cdnSlot = cdn({
ttl: 3600,
staleWhileRevalidate: 86400,
zoneId: (env) => env.CF_ZONE_ID,
purgeToken: (env) => env.CF_CACHE_PURGE_TOKEN,
});

This section is what a Worker adds to that page.

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.

Each entry is also keyed by the build that rendered it. A deploy therefore starts with an empty store: the new version renders its pages afresh instead of finding the previous version’s, whose stylesheet and script URLs the new deploy no longer serves, and two versions in a gradual rollout never read each other’s pages. The previous version’s entries are left to expire by their ttl.

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.

Cloudflare’s Workers Caching puts a cache in front of a Worker entrypoint, and it is the recommended way to cache a Plumix site on Workers. It reads the Cache-Control and Cache-Tag headers the provider writes, needs no zone cache rule, and collapses concurrent requests for the same page into one render, which the Cache API store above does not. A scaffolded site has it on, with this block in wrangler.jsonc (Wrangler 4.107 or later):

"cache": { "enabled": true },
"exports": {
"default": { "type": "worker", "cache": { "enabled": false } },
"PlumixSharedCache": { "type": "worker", "cache": { "enabled": true } },
},

The generated Worker has two entrypoints. The default export is a gateway with caching off, so it runs on every request. It hands a request to PlumixSharedCache, the entrypoint the cache sits in front of, only when it is a GET or HEAD from a visitor with no session (your authenticator’s hasSession, so a header session counts), no Authorization header, no ?preview or ?plumix.edit, and a path outside /_plumix/ (under your base path). Everything else renders live and is never cached. The forwarded request has every cookie removed, drops the marketing parameters Cloudflare APO ignores (utm_*, gclid, fbclid, msclkid, _ga, mc_cid and the rest of that list), and sorts the parameters left, so one copy per URL serves every anonymous visitor, whatever cookies or campaign tags they carry. The page renders from that forwarded URL.

plumix build and plumix dev fail when Workers Caching would sit in front of the default export, as it does under a bare top-level "cache": { "enabled": true }. That cache would answer signed-in visitors with the anonymous page without running the gateway, and no purge would reach it. To move an existing site to this shape, add the exports block above beside its cache block.

A cached page must not set cookies: Workers Caching bypasses any response that carries Set-Cookie, so a theme or plugin that sets one on a public page turns its cache off for that page.

It holds only the anonymous public page. A signed-in visitor runs in the default entrypoint, which loads their session and, on a page with no access policy, reads the anonymous copy from PlumixSharedCache as CDN Caching describes: with cookies removed and the same normalized URL, so it is the copy anonymous visitors are served. It is served to them as private, no-cache with vary: cookie only when the page marked it the same for every visitor; otherwise they render live, and their own render is never cached. Staff, who get the admin bar, and a member whose locale is their own always render live. The gateway marks what it forwards with an internal header, and strips that header from every request a client sends, so only the gateway’s own requests keep the universal marker on their response; it strips the marker before any response leaves. Every other segment’s response leaves the Worker as private, no-store with vary: cookie, whether it was rendered or served from the Cache API store, which widens only its own stored copy. Workers Caching does not store a private, no-store response, so the segment copies stay in the Cache API store. An anonymous page the provider does not decorate, such as a search page, an archive that did not opt into cacheable, or a 404, leaves as private, no-cache, so Workers Caching does not keep it under its own defaults with no tag to purge. Both caches are needed, and a publish purges both.

Its purge is reachable only from Worker code, and only from the entrypoint whose copies it clears; the zone purge behind your Cache Purge token does not reach it. So plumix build and plumix dev read your wrangler config, and when PlumixSharedCache is cached the generated Worker hands the provider from @plumix/runtime-cloudflare/cdn a purge that runs inside that entrypoint, through an RPC call. Each request’s tags then go to the zone and to Workers Caching in the same flush, at most 1,000 tags per Workers Caching call, a scheduled publish purges the same way, and a sync purges it of everything. A refused purge is logged with Cloudflare’s errors, recorded on the request’s cdn telemetry, and the publish still succeeds; the page then expires within ttl. Workers Caching purges always run at Cloudflare’s free-tier rate limits, whatever the plan, so a site publishing in bulk can meet them.

Two things keep it from working. Core’s provider from plumix/cdn/cloudflare cannot reach the cached entrypoint, so a Worker that imports that one leaves Workers Caching serving the old page after a publish. And the provider stays inert until zoneId and purgeToken resolve, so on a workers.dev host no page is decorated: every public page leaves private, no-cache and renders on each request, and Workers Caching stores none of them.

It also caches more than the cdn slot governs: every response PlumixSharedCache returns passes through it, not only the ones the provider decorates. The platform’s own endpoints never reach it, since the gateway renders /_plumix/ live, so what is left undeclared is whatever a plugin’s own public route returns without saying.

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.

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.

A task also runs within a budget, five minutes unless it declares its own timeoutMs, so it is cut off from the firing before Workers’ own limit. One that outruns it is logged as a warning and the next task runs, but a timeout alone does not mark the invocation failed, unlike plumix cron run, which exits non-zero on it.

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.

A Worker keeps logs only when its config turns Workers Logs on, and the scaffolded wrangler.jsonc does:

"observability": { "enabled": true },

With it, Workers Logs records each request with its errors and uncaught exceptions, plus whatever Plumix writes to the console: a block that failed to render, a scheduled task that threw, a deferred promise that rejected. You search them in the dashboard under the Worker’s Observability tab, including what happened before anyone was watching. wrangler tail streams the same events live, but it shows nothing from before you attached it.

Workers Paid includes 20 million log events a month. A busy site can log a share of its requests instead of every one with head_sampling_rate, a fraction from 0 to 1:

"observability": { "enabled": true, "head_sampling_rate": 0.1 },

plumix doctor warns when the config leaves Workers Logs off, and prints the block to add.

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/auth";
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/auth";
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.

cfAccess replaces the request authenticator with a check on the Access JWT that Cloudflare Access issues once a request has passed the application’s policy. It reads the Cf-Access-Jwt-Assertion header, and on a request without that header it reads the CF_Authorization cookie, which carries the same signed token.

import { auth } from "plumix/auth";
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. It also takes a list, such as ["aud-preview", "aud-prod"], for preview and production hostnames that sit under separate Access applications; a token matching any entry is accepted. 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.

An Access application does not have to cover the whole site. The usual setup covers /_plumix/admin alone, because members use the public routes and their own RPC under /_plumix/rpc/user/*, which shares a prefix with the admin’s. Access sets the header only on the paths it covers, so the admin’s /_plumix/rpc/* calls arrive without it and authenticate through the host-wide CF_Authorization cookie instead. That stops working if the Access application enables the cookie’s Path attribute, which scopes the cookie to the covered paths. When a request carries the header, the header is the credential even if it fails to verify; the cookie is read only when the header is absent. One trade-off comes with the cookie: on a path Access does not cover, revoking a user’s Access session does not take effect until the token’s exp.

The CLI’s remote calls, such as plumix mail test --remote, the settle after plumix sync and the device flow that mints a token, carry a Plumix API token, and Plumix checks that token itself whichever authenticator the site uses. When Access covers the paths the CLI calls, it needs only to get the request past the Access edge: create an Access service token, add a Service Auth policy for it to the application, and set CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET where the command runs.

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. cfAccess handles that through signOutUrl: for a request that carries the Access header or the CF_Authorization cookie, the sign-out route sends the admin to https://your-team.cloudflareaccess.com/cdn-cgi/access/logout, which clears the Access session. A request with neither gets no Access logout, so chaining cfAccess with defaultAuthenticator() keeps a member signed in by magic link on their own sign-out. cfAccessLogoutUrl("your-team.cloudflareaccess.com") returns the same URL for a sign-out control of your own.

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 types forwards to wrangler types with your arguments appended. plumix deploy and plumix preview build the Worker for the environment --env names, run the check that the site’s own tables are not ahead of its migrations/, apply every owner’s pending migrations to that environment’s database, and only then run wrangler deploy or wrangler preview. When the build, the check or a migration fails they exit non-zero and wrangler never runs.

plumix dev applies every table owner’s pending migrations to the local D1 copy it uses, under .wrangler/state/v3, before the dev server opens it. plumix migrate applies to that local copy too. Every remote command reaches D1 through wrangler’s getPlatformProxy, and plumix sync also through wrangler d1 export and wrangler d1 execute, so it needs wrangler login or an API token, as wrangler does. They open the binding your d1() slot names; --binding <name> picks another.

The wrangler config is where a Cloudflare site’s databases are named, so plumix migrate, plumix sync, plumix import, plumix deploy and plumix preview read their target from it, through wrangler’s own config reader:

Terminal window
plumix migrate # the local dev database
plumix migrate --production # the production database
plumix migrate --preview # the Worker Previews database
plumix migrate --env qa [--preview] # a named environment's database
plumix deploy | plumix preview # migrate, then wrangler deploy | wrangler preview
plumix sync pull --url https://example.com --production # the production database's data, locally
  • --production is env.production’s d1_databases when the config defines an env.production, and the top level’s otherwise.
  • --env <name> is env.<name>’s d1_databases. It does not combine with --production, which fails with migrate_target_conflict.
  • --preview alone means production’s Worker Previews database, and with --env <name> that environment’s. It reads the environment’s own previews block, or the top-level previews when the environment has none, as wrangler does: previews is inherited, d1_databases is not.
  • CLOUDFLARE_ENV names the environment wherever no option does, as in wrangler, and --env wins over it.

A command connects to exactly the database_id of the block it resolved. A preview_database_id is the database wrangler dev opens, and no Plumix command targets it. When the block binds no D1 database, the command fails with migrate_no_d1 before it connects, naming the block and the binding and giving the JSON to add. It never falls back to another block.

A sync’s deployed end needs one of these options and fails with sync_remote_target_missing without it; Moving data between environments covers how it moves D1 and the storage slot. Each command prints its target first, such as → production (top level) · DB · site-prod or → preview (env.qa, previews inherited from top level) · DB · site-preview. A remote plumix migrate then prints its plan and asks before it writes; --yes skips the question and --dry-run stops after the plan. A CI script runs plumix migrate --production --yes. plumix doctor lists every D1 database a target can reach.

A QA environment has its own database:

wrangler.jsonc
{
"d1_databases": [
{
"binding": "DB",
"database_name": "site",
"database_id": "<production id>",
},
],
"env": {
"qa": {
"d1_databases": [
{
"binding": "DB",
"database_name": "site-qa",
"database_id": "<qa id>",
},
],
},
},
}

plumix deploy --env qa builds the Worker with CLOUDFLARE_ENV=qa, applies the pending migrations to site-qa, and runs wrangler deploy --env qa.

Workers Builds deploys a non-production branch with wrangler preview, which takes its bindings from the previews block. Give previews a database of their own so a preview build never writes to production:

  1. Create it: wrangler d1 create site-preview.

  2. Add it under previews, with the same binding as production:

    wrangler.jsonc
    {
    "previews": {
    "d1_databases": [
    {
    "binding": "DB",
    "database_name": "site-preview",
    "database_id": "<preview id>",
    },
    ],
    },
    }
  3. In the Workers Builds settings, set the non-production branch deploy command to pnpm plumix preview.

plumix preview builds, applies the pending migrations to the previews database, and runs wrangler preview with your arguments, such as --name, appended. The top-level previews database is shared by every branch’s Preview, and by each environment without a previews block of its own, so the plan says so when it targets that one.

On Workers Builds, set the production deploy command to npx plumix deploy. The remote apply then runs with the build’s own Cloudflare credentials, the same ones wrangler deploy uses, so each version goes live only once the database has what it needs.

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.

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.