Deploying Cusp

Get a Cusp deployment from zero to the first-run wizard on your own Cloudflare account

This guide is for technical deployers — agencies, in-house developers, and contributors deploying Cusp on behalf of an editor. Editors do not read this. They click the Deploy to Cloudflare button in the README and land in the first-run wizard. There is no server to manage and no external service to sign up for: Cusp runs entirely on the Cloudflare Developer Platform.

Three ways to deploy

  • Deploy to Cloudflare button — the README entry point. It reads the cloudflare.bindings block in package.json, provisions every binding, deploys the Worker, and drops you on the workers.dev URL. No terminal. Best for editors clicking through, or any deployer who does not need to name resources or pick the account.
  • pnpm cusp:bootstrap — a one-shot CLI for deploying from your own laptop or from CI against an arbitrary Cloudflare account, with predictable resource names. This is the path most of this guide covers.
  • Manual — provision the resources by hand with wrangler, paste the IDs in, and deploy. You should never need it, but the steps are at the end for completeness.

Quickstart: pnpm cusp:bootstrap

pnpm install
pnpm cusp:bootstrap         # uses defaults: prefix = directory name, current wrangler account
# override the defaults explicitly:
pnpm cusp:bootstrap --prefix my-site --base-url https://my-site.example.com

Bootstrap runs in order: reads wrangler.jsonc and notes the binding names; resolves the resource prefix (default: the directory name — override with --prefix) and the optional public base URL (--base-url); provisions D1, R2, and KV idempotently against your current wrangler account (override with --account); writes the provisioned IDs into a gitignored overlay; builds the Worker; deploys; then pings /healthz to trigger the first request so migrations apply. It does not prompt and does not run wrangler whoami — run wrangler login (or pass --account) beforehand to target the right account.

The script is fully non-interactive — it never prompts. Other flags: --skip-deploy provisions and writes the configs but stops before wrangler deploy; --dry-run prints what it would do without changing anything; --yes is the explicit non-interactive marker, using defaults and failing on ambiguity rather than asking (useful in CI).

The script is idempotent and non-destructive. Re-running it against an account where resources already exist reads their IDs from .cusp-deploy.json and reuses them rather than creating duplicates, and it never deletes a resource. To start fresh, delete the resources by hand and remove .cusp-deploy.json.

What gets provisioned

Cusp is one Worker bound to one D1 database, one R2 bucket, and one KV namespace, plus the Durable Object, Workflow, email, and dynamic-loader bindings declared in wrangler.jsonc. Bootstrap creates the three stateful resources under your prefix:

  • D1 — the database, named {prefix}-cms. Holds collections, documents, versions, users, sessions, settings, and the rest of the schema.
  • R2 — the bucket, named {prefix}-main. A single bucket organised by prefix: documents, media, rendered pages, themes, plugins, versions, and backups.
  • KV — the namespace, named {prefix}-kv. Session fast-path, schema cache, rendered fragments, and computed aggregates.
  • Worker — the deployment itself, carrying all four logical surfaces: the public render path, the admin API, the headless API, and the admin SPA served from Static Assets.

The prefix defaults to the working directory's name and must match /^[a-z][a-z0-9-]{0,31}$/ — a lowercase letter followed by up to 31 lowercase letters, digits, or hyphens. Bootstrap also rewrites the Workflow names to your prefix so two Cusp deploys can share one Cloudflare account without colliding.

Where configuration lives

The committed wrangler.jsonc keeps placeholder IDs (REPLACE_WITH_D1_ID and the like) so the file is committable and the Deploy to Cloudflare button stays functional. Bootstrap generates a gitignored overlay carrying your tenant-specific values, and wrangler reads it via the --config flag.

  • wrangler.jsonc — committed. The shape of the Worker, with placeholder IDs.
  • wrangler.deploy.jsonc — not committed. Tenant-specific IDs, generated by bootstrap.
  • .cusp-deploy.json — not committed. The source of truth for the bootstrap script's idempotent re-runs.
  • .dev.vars — not committed. Local secrets and the local public base URL.

Migrations apply on the first request

Cusp never asks a deployer to run a migration command against production. After the Worker deploys, the embedded request-time runner applies any pending migrations on the first request it sees — and the first-run wizard load is that request. The migrations are embedded into the Worker bundle at build time as static strings, not fetched from R2 or any remote source.

Concurrent requests immediately after a deploy do not race: the runner takes a row-lock in the _migration_state table, applies pending migrations in lexical order, and marks each applied. A request arriving during a long migration waits on the same in-flight work, so the worst case is a slow first response, never a broken one. Before running a migration's DDL, the runner inspects the live schema and short-circuits to applied when the target tables, columns, and indexes are already present — so a restored backup or an out-of-band schema change converges in one request instead of looping.

Local development is the exception: pnpm db:migrate:local runs wrangler's migration apply against the local D1 emulation so wrangler dev starts on a schema that matches the bundle.

Do not run wrangler d1 migrations apply against a remote database. Wrangler tracks migrations in its own d1_migrations table, which is independent of Cusp's _migration_state. Running the wrangler command lands the SQL but leaves the embedded runner believing nothing happened, and the next request fights with already-applied DDL. There is intentionally no pnpm db:migrate:remote. The embedded runner is the only supported remote-migration path.

Subsequent deploys

Once bootstrap has done the one-time provisioning, use pnpm deploy for later deploys. It regenerates the overlay from the committed wrangler.jsonc — so any bindings added since the last bootstrap (a new workflow binding, say) land in the next deploy — then runs wrangler deploy against the overlay. Migrations still apply on the first request after the deploy.

pnpm deploy

Email setup

Cusp uses Cloudflare's send_email binding for password-reset messages, the setup-wizard verification ping, and mail sent on behalf of plugins that declare the email capability. The binding deploys without configuration, but every send fails until you verify a destination address in the Cloudflare Email Routing dashboard for the deploy's zone. The From address is noreply@{CUSP_EMAIL_DOMAIN}, where CUSP_EMAIL_DOMAIN is a deployment var in wrangler.jsonc that ships empty; when empty it falls back to noreply@cusp.local, which Email Routing rejects on any real domain-verified deploy.

wrangler.jsonc
"vars": {
  "SITE_NAME": "",
  "CUSP_EMAIL_DOMAIN": "yourdomain.example"
}

Set CUSP_EMAIL_DOMAIN to the sending domain you verify in Email Routing, then redeploy. On a workers.dev deploy with no verified domain, leave it empty — the binding is a noop there. The install persists an email-routing status, and whenever a send fails the admin shell shows an informational EmailStatusBanner across the admin — not a wizard step, and with no clickable URL and no dismiss button. Nothing blocks: administrators can reset other users' passwords from the user-management screen without email, and the banner clears on its own once a send succeeds.

What happens after the wizard

When the first-run wizard completes, the install handler bootstraps three long-lived daily workflows and records their instance IDs in the settings table: SweepMedia (hard-deletes soft-deleted media past its retention window, 02:00 UTC), ExportBackup (a full-site backup tarball under the backups prefix in R2, 03:00 UTC), and PrunePolicies (enforces per-entity-type version-retention caps, 04:00 UTC). The admin Health page surfaces the ExportBackup and PrunePolicies instance IDs under long-lived workflows (SweepMedia runs on the same schedule but is not shown there), alongside recent backups with a Run backup now action, restore imports, time-travel restores, and a Rebuild search index action.

Recovering from a stuck deploy

If the first request after a deploy hits a migration that fails partway, the deployer sees a friendly error page — not a bare 500 — naming the failing migration and the wrangler tail command to read the underlying error. The recovery path, in order of safety:

  1. Wait and retry. Most failures are transient — a SQLITE_BUSY, or a cold start running long. Refresh the page; the runner picks up where it left off.
  2. Read the logs. Run wrangler tail with --format=pretty for your worker name. The runner logs the failing SQL, the error, and the request ID.
  3. Roll back manually. If a migration is genuinely broken, drop its _migration_state row and the partially-applied tables, then redeploy with the fixed migration.

Two common surface errors: wrangler refuses to deploy with an invalid UUID message when wrangler.jsonc still has its placeholder IDs — that means bootstrap has not run yet, so run pnpm cusp:bootstrap first. And if the deploy succeeds but /setup shows the friendly error page, a migration is stuck; follow the wrangler tail link. A no such column error usually means the Worker was built against an older migration set than is in D1 — rebuild and redeploy.

Custom domains

Custom-domain wiring is not part of cusp:bootstrap. Attach a domain via the Cloudflare dashboard or wrangler triggers deploy, then re-run bootstrap with --base-url set to your domain to regenerate the overlay with the right public base URL, and redeploy.

Manual deploy

Bootstrap exists so you do not have to do this. The manual path provisions the same three resources and wires their IDs into the committed config by hand — useful only when you need full control over the wrangler invocation or cannot run the script.

  1. Clone the repository and run pnpm install. Then run wrangler login (or set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN) so wrangler targets the right account.
  2. Create the resources: wrangler d1 create cusp-cms, wrangler r2 bucket create cusp-main, and wrangler kv namespace create cusp-kv. Note the database_id D1 prints and the id KV prints; R2 binds by name, so it has no ID.
  3. Edit wrangler.jsonc: replace REPLACE_WITH_D1_ID under the DB binding with the D1 database_id and REPLACE_WITH_KV_ID under the KV binding with the KV namespace id. Leave the BUCKET binding's bucket_name as-is unless you named the bucket differently.
  4. Set CUSP_EMAIL_DOMAIN in the vars block if you are sending email from a verified domain (see Email setup above).
  5. Build and deploy: pnpm build then wrangler deploy. Open the workers.dev URL the deploy prints — the first request triggers the embedded migration runner and lands you in the first-run wizard.

Read the migration runner decision record