Deploy Your Site
A scaffolded project runs locally against a placeholder database and a localhost passkey origin. Deploying means replacing both with real values and building the site for wherever it runs next. What that looks like depends on the runtime: pnpm create plumix-app scaffolds Node by default, which builds to a directory and a command you run on any host that runs Node, while --runtime cloudflare scaffolds a Worker, which plumix deploy hands to wrangler deploy.
Overview
Section titled “Overview”On the Node runtime, plumix build writes dist/client and dist/server/worker.js, and deploying is running that file with node wherever you host it — a VM, a container, anywhere Node runs. There is no separate deploy command, because there is no platform API to call: you own the process.
On the Cloudflare runtime, plumix deploy hands off to wrangler deploy, so what ships is whatever wrangler.jsonc describes plus the bundle plumix build produced.
Either way, three things in a scaffolded project point at localhost and need real values before the site is live: the passkey origin, the database, and any secret you have been reading out of .env or .dev.vars. The sections below cover each runtime’s version of that swap.
Quickstart
Section titled “Quickstart”This is the path from a scaffolded site (pnpm create plumix-app, the default runtime) to a running process. Node.js covers the runtime in full; this is the shortened version.
-
Point the passkey at the deployed host. In
plumix.config.ts, setrpIdto the host you are deploying to andoriginto itshttps://address (orhttp://only if you genuinely serve plaintext, which a passkey ceremony run over the open internet should not).import { auth } from "plumix";export const recipeAuth = auth({passkey: {rpName: "Recipes",rpId: "recipes.example",origin: "https://recipes.example",},}); -
Set every secret and environment variable the config resolves. There is no
.dev.varsupload step:process.envon the host is the whole story, so setRESEND_API_KEY,GITHUB_CLIENT_SECRETand the rest through whatever your host uses — a.envfile the process manager loads, its own secrets UI, or the shell that starts the process.PORTandHOSTcontrol what the server listens on, defaulting to3000and0.0.0.0. -
Generate and apply the migrations.
Terminal window pnpm exec plumix migrate generatepnpm exec plumix migrate applygeneratewrites the resolved schema and emits SQL intodrizzle/;applyruns the pending files against the SQLite filenodeSqlitenames. Run it again after adding a plugin that carries its own tables. Point it at the production database path, not the one you have been developing against. -
Build.
Terminal window pnpm buildplumix buildwritesdist/clientfor the browser anddist/server/worker.jsto run. Everything the server needs is inlined into that one file, except native packages, which it imports at runtime. -
Run it.
Terminal window PORT=3000 node dist/server/worker.jsRun this under whatever keeps a process alive on your host — systemd, a container orchestrator,
pm2. ASIGTERMstops the process cleanly: it stops accepting new requests and gives in-flight work — requests, a scheduled run, deferred work such as telemetry delivery — one ten-second budget. If everything finishes it exits0; anything the budget cuts makes it exit1. -
Claim the admin account. Open
https://recipes.example/_plumix/adminand create your passkey before anyone else does. The database is empty, so the first registration wins the admin account and every later one is refused.
Node ships no kv, and uploads and the image cache live on the one machine the process runs on. Node.js covers what that means and when plumix/storage/s3 or plumix/db/libsql are worth swapping in instead of the disk-backed defaults. Public pages render on every request until you put a CDN in front of the process and declare the cdn slot, which is the same declaration a Worker carries — CDN Caching is that path end to end.
Deploying to Cloudflare instead? This is the path from a scaffolded site (pnpm create plumix-app --runtime cloudflare) to a live Worker. It assumes a signed-in wrangler. Cloudflare Workers covers the adapter in full; this is the shortened version.
-
Create the database and paste its id.
Terminal window wrangler d1 create recipesCopy the
database_idit prints into thed1_databasesentry inwrangler.jsonc, replacinglocal-development-only. -
Point the passkey at the deployed host. In
plumix.config.ts, replace the scaffoldedcloudflareDeployOriginspread with literal values. AnchorrpIdto yourworkers.devsubdomain and allow the wildcard beneath it, so one passkey covers production and every preview branch, and keep the localhost pair behindprocess.env.PLUMIX_DEV, which the Vite plugin substitutes as"1"underplumix devand empty underplumix 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"],}),},});Running on your own domain instead? Drop
allowedOriginsand writerpId: "recipes.example"withorigin: "https://recipes.example". Previews onworkers.devthen need magic links or Cloudflare Access, because the two are different registrable domains. The helper’sproductionOriginoption says the same thing when the build runs on Workers Builds, paired withdefaultBranchif your production branch is notmain. -
Set every secret the config resolves. One command per name in
.dev.vars:Terminal window wrangler secret put GITHUB_CLIENT_IDwrangler secret put GITHUB_CLIENT_SECRET -
Apply the migrations to the remote database.
Terminal window pnpm exec plumix migrate generatepnpm exec plumix migrate apply --remotegeneratewrites the resolved schema and emits SQL intodrizzle/;apply --remoteruns the pending files against the D1 database you just created. Run it again after adding a plugin that carries its own tables. -
Build and deploy.
Terminal window pnpm buildpnpm exec plumix deployplumix buildbuilds the client bundle first and then the worker, because the worker bakes the client’s asset manifest into itself.plumix deploypasses any extra arguments straight towrangler deploy. -
Claim the admin account. Open
https://recipes.<your-account>.workers.dev/_plumix/adminand create your passkey before anyone else does. The database is empty, so the first registration wins the admin account and every later one is refused. This is the step the caution above blocks while a locally built deploy still carries the localhost origin.
Resources the config expects to exist
Section titled “Resources the config expects to exist”Adding a plugin or a slot can add a binding, and wrangler deploy will not create the resource behind it.
Choosing the media plugin puts an r2_buckets entry named <project>-media in wrangler.jsonc. Create the bucket before the first deploy:
wrangler r2 bucket create recipes-mediaThe kv slot is the case the scaffolder does not cover. The Cloudflare runtime declares a kv capability that carries a kv_namespaces binding, but the scaffolder only applies a capability that a selected plugin asks for, and no shipped plugin asks for kv. Nothing regenerates wrangler.jsonc after scaffolding either, so adding kv({ binding: "KV" }) to plumix.config.ts leaves the worker with no namespace bound. Create the namespace and add the binding by hand:
wrangler kv namespace create KV"kv_namespaces": [{ "binding": "KV", "id": "<the id wrangler printed>" }]The CDN needs no resource, but it does need two credentials before it stores anything. cloudflare() takes a zoneId and a purgeToken — read from the environment through a resolver in your config — and returns an inactive CDN when either resolves to nothing, because a CDN with no way to purge would serve a stale page forever. A workers.dev host has no zone, so caching starts when you attach a domain. One more step is then yours: Cloudflare does not cache HTML without a cache rule, so read CDN Caching before concluding the headers are not working.
Deploying from git
Section titled “Deploying from git”Connecting the repository to Cloudflare’s build service is the one case where step 2 can be skipped. The build service sets WORKERS_CI and WORKERS_CI_BRANCH, the Vite plugin freezes both into the bundle, and cloudflareDeployOrigin reads them, so the scaffolded spread resolves the deployed host on its own. Replace the scaffolded your-account placeholder with your real subdomain and leave the spread in place — a passkey anchored to the placeholder matches no browser.
What the build service gives you is a deploy per push. A push to the default branch ships production on recipes.your-account.workers.dev, and a push to any other branch ships that branch’s own <branch>-recipes.your-account.workers.dev preview host.
The helper anchors the passkey relying-party id to your-account.workers.dev and allows the account-wide wildcard origin, and the step 2 block writes the same pair out by hand. Either way one passkey enrolled once works on production and on every per-branch preview, because they are all subdomains of the same registrable domain.
A custom production domain breaks that span, and no configuration can restore it. Workers Builds does not expose the custom domain either, so the helper needs productionOrigin: "https://recipes.example" to anchor the default branch to it, and previews stay on workers.dev. Pass defaultBranch alongside it if yours is not main — production would otherwise resolve as a preview and never reach the custom domain at all. A custom domain and workers.dev are different registrable domains, so a credential anchored to one cannot verify against the other. Sign in to previews with magic links or Cloudflare Access when production runs on your own domain.
Checking the Cloudflare deploy
Section titled “Checking the Cloudflare deploy”plumix types runs wrangler types to regenerate the binding types, which is worth doing after any change to wrangler.jsonc.
Checking the deploy
Section titled “Checking the deploy”plumix doctor prints what the CLI resolved from your config, which is the runtime name, the database kind, the plugins with their versions, and a schema tables count. That count sums the tables the plugins declare and nothing else, so a project with no schema-carrying plugin reports 0 even though core’s entries, terms, users, sessions and credentials are all in the generated schema. Run it before a deploy after adding a plugin, to confirm its tables arrived. This works the same way on both runtimes.
Related
Section titled “Related”Deployment is the section this page compresses, and it covers what each step here glosses over. Node.js and Cloudflare Workers cover each runtime’s slots one at a time. Bindings and Environment covers how a Cloudflare binding in wrangler.jsonc reaches a config slot. Secrets covers .dev.vars, EnvInput and production secrets in full. Configuration is the reference for the slots you edited above. Passkeys covers the relying-party id, allowed origins and adding a second device.
Next steps
Section titled “Next steps”Read Deployment next for the parts this page names but does not explain, particularly Bindings and Environment and Secrets. If the site is live but empty, Content Modelling is where the model gets built, and Plugins covers installing Blog or Pages if you want their entry types rather than your own.