Skip to content

Overview

A Plumix site deploys as one Node.js process, or as one Cloudflare Worker — Node is the default pnpm create plumix-app scaffolds, and Cloudflare is one flag away with --runtime cloudflare. This page is the Worker’s story: the build turns plumix.config.ts into a worker entry, stages the admin and your static files beside it, and every resource the site reads at runtime arrives as a binding on the Worker env. Node.js is the other shape, where the same build produces a directory and a command — read that page first if you scaffolded with the default runtime.

Two files decide what a deploy is. plumix.config.ts picks the runtime adapter and names the binding each slot reads. wrangler.jsonc declares those bindings as real Cloudflare resources and sets the Worker’s compatibility flags. A binding name appears once in each file, and the two spellings have to match or the first request fails with a list of what is missing.

Secret values are in neither file. A secret’s name may appear in wrangler.jsonc under secrets.required, which makes wrangler deploy fail when the value is not configured, but the value itself arrives on env only when a request runs. That is why a secret-bearing slot takes an (env) => value resolver instead of a literal. Read Secrets for that shape.

Everything else is a build output. plumix build writes a generated worker entry, a generated Drizzle schema, and a static directory holding the admin SPA plus anything in your public/ folder. You commit none of it.

There is no origin server. The Worker answers the public site, the admin at /_plumix/admin, and the cron invocations, all from the same script.

The scaffolder adds plumix to your project rather than to your PATH, so reach it through your package runner.

Terminal window
pnpm exec plumix deploy

The rest of this page writes the commands bare. wrangler works the same way.

This is the path from a scaffolded recipe site to a live one. It assumes a site made with pnpm create plumix-app and a signed-in wrangler.

  1. Point the passkey at the deployed host. The scaffolded plumix.config.ts spreads cloudflareDeployOrigin into auth.passkey, and that spread resolves a real host only on a Workers Builds build. For the local build in step 3, replace it with a literal rpId of your-account.workers.dev, an origin of https://recipes.your-account.workers.dev and allowedOrigins: ["https://*.your-account.workers.dev"], keeping the localhost pair behind process.env.PLUMIX_DEV. That one wildcard covers production and every preview branch. Cloudflare Workers has the block.

  2. Create the database. The scaffolded wrangler.jsonc ships a placeholder database_id that works only for local dev.

    Terminal window
    wrangler d1 create recipes

    Paste the returned id into the d1_databases entry in wrangler.jsonc. Both this edit and step 1 have to land before the build, because the build copies wrangler.jsonc into the bundle directory and bundles the config module into the worker entry.

  3. Build the site.

    Terminal window
    plumix build

    The build fails here rather than in production if a plugin registration or a config slot is wrong, because it constructs the whole app before Vite starts.

  4. Deploy.

    Terminal window
    plumix deploy

    Core and every plugin that owns tables ship their own migration history. plumix deploy builds the Worker, then applies each one’s pending migrations, and the site’s own, to the D1 database wrangler.jsonc names for the environment, through wrangler’s remote bindings, so it needs wrangler login or an API token, as wrangler does. Then it runs wrangler deploy with your arguments appended. When a migration fails, or the site’s own tables are ahead of its migrations/, it stops before wrangler deploy runs.

  5. Set the secrets your resolvers read. One command per key.

    Terminal window
    wrangler secret put RESEND_API_KEY

    You can run this before the first deploy too. Point wrangler secret put at a Worker that does not exist yet and it asks whether to create one. That prompt defaults to yes and answers itself yes in a non-interactive shell, uploads a stub Worker under the name, then stores the secret on it. Name the key under secrets.required in wrangler.jsonc and the deploy refuses until the value is set, telling you to ship it with wrangler deploy --secrets-file <path>.

  6. Enrol the first passkey. Open /_plumix/admin on the deployed origin. The first admin has to arrive through the passkey bootstrap rail, so do this before inviting anyone.

plumix build regenerates the .plumix/ directory before Vite starts. Nothing in it is yours to edit, and every file carries a header saying so.

.plumix/worker.ts is the Worker entry, and wrangler.jsonc points its main at it. The runtime adapter writes it — config.runtime.generateEntry() — because the entry is the one file whose shape belongs to the platform: a module-worker export default here, something else on a runtime that is not Workers. The entry Cloudflare emits imports your config, calls buildApp once per isolate, has core build the handler from the adapter’s handler spec with createRuntimeHandler(app), and hands each request to that handler along with an invocation built from the Worker’s env and waitUntil. Regenerating it on every build is what keeps the entry in step with the config.

.plumix/schema.ts is the Drizzle schema assembled from core’s tables plus every table a plugin contributes.

.plumix/client-entry.ts is the browser bundle’s entry, and it carries the CSS files your theme declared. Plumix emits it even when it is empty, because the Vite plugin lists it as a client entry unconditionally.

Three more entries are emitted on every build and each becomes its own client chunk. .plumix/islands-entry.ts imports the island runtime that registers the <plumix-island> custom element, and the server injects it only on a page that contains one. .plumix/islands-renderer-entry.ts is the React half of that runtime, dynamically imported on first hydration so React is never fetched for a page whose islands all defer. .plumix/editor-entry.ts carries the editor canvas and the block modules it renders, and the server injects it only when the edit gate authorizes the request.

Each deploy removes the previous build’s chunks, so a tab opened before it can fail to import an island’s chunk or the renderer. The island retries the import once, then dispatches a cancelable plumix:hydration-error on window. If no listener cancels it, the page reloads and loads the current build. It reloads at most once per URL in a browser session, and never when sessionStorage is blocked, so a chunk missing from the new build too can’t loop. Any other hydration error never reloads, and neither does plumix dev, which serves source and shows the failure in its island overlay. To keep the server HTML instead, cancel the event:

window.addEventListener("plumix:hydration-error", (event) => {
event.preventDefault();
});

.plumix/public holds the static half: your public/ directory copied in, and the precompiled admin SPA staged under _plumix/admin/.

Vite then builds twice, client first and worker second, because the worker bakes the client’s asset manifest into its own bundle. The output is a dist/client directory of static assets and a dist/<worker-name>/ directory holding the worker bundle and a wrangler.json the Cloudflare Vite plugin writes from your wrangler.jsonc. The plugin also drops a .wrangler/deploy/config.json redirect pointing at that copy, so plumix deploy ships the built snapshot rather than re-reading the file you edited.

Each output also gets a THIRD-PARTY-NOTICES.md listing the name, version, licence and licence text of every dependency that build bundled. The one in dist/client is a static asset, so it is served publicly at /THIRD-PARTY-NOTICES.md. The one in dist/<worker-name>/ covers the worker bundle and stays with it. The prebuilt admin carries its own notices under /_plumix/admin/. The files come from Vite’s build.license, and you turn them off through Vite’s own option in plumix.config.ts:

plumix.config.ts
import { plumix } from "plumix";
export default plumix({
// …your runtime, database and auth
vite: { build: { license: false } },
});

Your config module evaluates twice. plumix build runs it on your machine to write .plumix/schema.ts and .plumix/worker.ts, and that generated entry imports it again, so it also runs once per isolate inside the deployed Worker. The Vite plugin freezes seven process.env member expressions into the bundle as literals, WORKERS_CI, WORKERS_CI_BRANCH, PLUMIX_DEV, PLUMIX_EDITOR, PLUMIX_EDITOR_PATH_MAP, PLUMIX_FORWARD_ERRORS and PLUMIX_BUILD_ID, and it rewrites those spellings and nothing else. Every other read of process.env runs inside the Worker, where your shell is not. Reading one of the first six through a variable instead of writing process.env.NAME out in full puts it on the wrong side of that line, and the value never reaches the deploy. PLUMIX_BUILD_ID is not yours to set: the plugin generates a fresh id for each build and keys cached pages by it, so a value in your shell is ignored.

Bindings and secrets exist only inside a request. The handler connects each slot on the first request by handing it the Worker env, so d1({ binding: "DB" }) stores the string "DB" at build time and resolves it to a live D1 handle when the first request arrives. The bound instances are reused for the rest of the isolate’s life, because env does not change under it. The one exception is a database adapter’s connectRequest callback, which runs per request; that is how D1’s Sessions API attaches a bookmark to each response.

That split is why the secret-bearing slots take a resolver. mailer, an OAuth provider’s clientSecret, passkey.origin and R2’s S3 credentials all accept either a literal or an (env) => value function, and the framework calls the function once per isolate with the real env. R2’s S3 credentials are the odd one there. An R2 binding cannot mint a presigned upload URL, so the adapter needs an account id and an S3 key pair on top of the binding before it can offer one.

Core and every plugin that ships its own tables carry their own migration history, so a site does not generate migrations to set up its database. Registering an entry type adds no table, so most content modelling needs no migration at all. A plugin that ships its own tables does.

No migration command is part of a deploy. plumix dev applies every pending migration to your local database before it serves, and writes the next migration of the site’s own tables when they changed, for you to commit. plumix deploy applies the pending ones to the remote D1 binding your d1() slot names before it runs wrangler deploy. On Node and Bun the built server applies them when it starts. CLI Reference covers each owner’s tracking table, the check that the site’s own history is up to date, and how a database set up by an earlier release is adopted.

Core registers two scheduled tasks: a daily session cleanup and a publish-scheduled task that flips due entries from scheduled to published. Neither runs unless the Worker declares a matching cron trigger.

wrangler.jsonc
{
"triggers": {
"crons": ["0 3 * * *", "*/5 * * * *"],
},
}

Workers Builds gives every branch its own *.workers.dev host. A passkey is anchored to a hostname, so a credential enrolled on production will not verify on a preview unless the two share a registrable domain.

Anchor rpId to your-account.workers.dev and add the allowedOrigins wildcard https://*.your-account.workers.dev, and one passkey spans production and each preview branch, because every one of those hosts sits under the same registrable domain. cloudflareDeployOrigin derives that pair from the build environment for you, on a build that runs on Workers Builds; write it out yourself when you build locally. See Cloudflare Workers for both shapes and for what changes when production runs on a custom domain.

Your passkey origin is a deploy concern and an identity concern at once, so Passkeys is worth reading alongside this page. Scheduled publishing is described from the content side in Statuses and Publishing. The generated files under .plumix/ are catalogued from the repository side in Project Structure. Runtime Adapters is the contract a runtime other than Cloudflare Workers would implement, and the obligations it takes on. CDN Caching is the one slot that survives a change of host untouched, and covers putting a CDN in front of a deploy that is not a Worker.

One page in this section is not written yet, and what it covers already ships. CLI Reference is the exhaustive listing of the seven commands and the five global flags.

Read Cloudflare Workers next for the adapters that fill each slot, then Bindings and Environment for how a name in wrangler.jsonc reaches your config. Secrets covers the values neither file may contain.

If you have not deployed anything yet, Deploy Your Site is the shorter path and comes back here for depth. Configuration is the reference for every config slot named above.