Deployment
This guide gets you from pnpm dev to a deployed Worker + D1 + R2
backend. It assumes you’ve followed the Quick
Start and want to ship it.
Prerequisites
Section titled “Prerequisites”- Cloudflare account with Workers enabled
wranglerCLI 3+ installed (pnpm add -g wrangler)wrangler logincompleted- Node.js 20+ locally
wrangler.jsonc
Section titled “wrangler.jsonc”{ "$schema": "node_modules/wrangler/config-schema.json", "name": "my-todo-app", "main": "./worker.ts", "compatibility_date": "2026-01-01", "compatibility_flags": ["nodejs_compat"],
"d1_databases": [ { "binding": "DB", "database_name": "my-todo-app-prod", "database_id": "<from wrangler d1 create output>" } ],
"r2_buckets": [ { "binding": "BUCKET", "bucket_name": "my-todo-app-blobs" } ],
"durable_objects": { "bindings": [ { "name": "COORDINATOR", "class_name": "SyncCoordinator" } ] },
"migrations": [ { "tag": "v1", "new_sqlite_classes": ["SyncCoordinator"] } ],
"observability": { "enabled": true }}d1_databases.DB— the SQLite DB plasma writes to.r2_buckets.BUCKET— wherefile()column bytes live.durable_objects.COORDINATOR— for realtime WebSocket poke fan-out. Optional if you’re happy with polling-only.
Create the D1 and R2
Section titled “Create the D1 and R2”wrangler d1 create my-todo-app-prod# Paste the printed database_id into wrangler.jsonc
wrangler r2 bucket create my-todo-app-blobsYour Worker entry point
Section titled “Your Worker entry point”import { createSyncHandler, ensureSchema, fromD1, gcOrphanedBlobs, r2Storage, runMigrations, SyncCoordinator,} from "@sh1n4ps/plasma-server"import { pokeCoordinator } from "@sh1n4ps/plasma-server/coordinator"import { schema, mutators, SCHEMA_VERSION } from "./shared/schema"
export { SyncCoordinator } // re-export so wrangler can find the DO class
interface Env { DB: D1Database BUCKET: R2Bucket COORDINATOR: DurableObjectNamespace}
export default { async fetch(req: Request, env: Env, ctx: ExecutionContext) { const executor = fromD1(env.DB)
// First-request DDL. Cheap once tables exist because CREATE // TABLE IF NOT EXISTS is a no-op. await ensureSchema({ schema, executor })
const handler = createSyncHandler({ schema, mutators, executor, schemaVersion: SCHEMA_VERSION,
auth: async (req) => { const token = req.headers.get("authorization")?.replace("Bearer ", "") if (!token) return { ok: false, reason: "no token" } const user = await verifyJWT(token) if (!user) return { ok: false, reason: "bad token" } return { ok: true, clientGroupID: user.id, clientID: req.headers.get("x-client") ?? crypto.randomUUID(), ctx: { userId: user.id }, } },
blobs: { default: r2Storage({ bucket: env.BUCKET }), },
// Poke every WebSocket-subscribed client in the group after a // successful push. Falls back to poll timer without this. onPushed: async ({ clientGroupID }) => { await pokeCoordinator(env.COORDINATOR, { room: `group-${clientGroupID}`, token: env.POKE_TOKEN, }) },
// Fire the Cache API put for blob GETs out of the request path. waitUntil: ctx.waitUntil.bind(ctx),
// Payload guards. Defaults shown; override for larger batches. maxMutationsPerPush: 200, // default maxPayloadBytes: 1_048_576, // 1 MiB default
// Push envelope validation (used when any column is .encrypted()). envelopeValidation: { maxCiphertextBytes: 8 * 1024, // reject > 8 KiB ciphertexts allowedKeyIds: ["k1", "k2"], // reject unknown keyIds },
// Server-side observability sink. onError: (err) => { console.error(`[plasma] ${err.kind}`, err) }, })
return handler(req) },
async scheduled(_event, env, ctx) { // GC orphaned blobs weekly. Configure cron trigger in dashboard. const executor = fromD1(env.DB) ctx.waitUntil( gcOrphanedBlobs({ executor, storage: r2Storage({ bucket: env.BUCKET }), minOrphanAgeMs: 7 * 24 * 3600 * 1000, limit: 500, }), ) },}Migrations at deploy time
Section titled “Migrations at deploy time”Migrations aren’t automatic. Two patterns:
Pattern A — one-shot admin route
Section titled “Pattern A — one-shot admin route”// Add to worker.ts fetch dispatchif (url.pathname === "/admin/migrate") { if (req.headers.get("x-admin-token") !== env.ADMIN_TOKEN) { return new Response("forbidden", { status: 403 }) } const result = await runMigrations({ schema, executor: fromD1(env.DB) }) return Response.json(result)}Trigger from your deploy script:
wrangler deploycurl -X POST -H "x-admin-token: $ADMIN_TOKEN" https://my-app.workers.dev/admin/migratePattern B — pre-deploy migration via wrangler
Section titled “Pattern B — pre-deploy migration via wrangler”For schema changes you can express as raw SQL, use wrangler d1 migrations — but you’re on your own to keep it in sync with
schema.ts. plasma’s runMigrations is the canonical path.
Deploy
Section titled “Deploy”wrangler deployThe Worker deploys to my-todo-app.<your-subdomain>.workers.dev.
Point your frontend’s endpoint there:
const plasma = createPlasmaClient({ ..., endpoint: "https://my-todo-app.<your-subdomain>.workers.dev/sync",})For a custom domain, add a route in your Cloudflare dashboard and
update the endpoint.
Environment variables
Section titled “Environment variables”{ "vars": { "PUBLIC_KEY": "..." }}Secrets (like ADMIN_TOKEN, JWT signing keys):
wrangler secret put ADMIN_TOKENwrangler secret put JWT_SIGNING_KEYAccess in worker.ts:
interface Env { DB: D1Database BUCKET: R2Bucket COORDINATOR: DurableObjectNamespace PUBLIC_KEY: string // from vars ADMIN_TOKEN: string // from secrets JWT_SIGNING_KEY: string // from secrets}Observability
Section titled “Observability”"observability": { "enabled": true } in wrangler.jsonc turns
on the Workers observability dashboard. plasma’s sync handler emits
structured errors that show up in Log Streams:
push-http/pull-http/network/schema-mismatch/rebase-replay/blob-upload-failed— from your client-sideonErrorhandler if you wire it toconsole.errorblob-read-auth-cap-hit— surfaces whenreadAuthMaxRefswas reached during a blob read auth check
For metrics beyond what Workers offers, use Analytics Engine to count push / pull calls per minute and Grafana Cloud for dashboards.
Deploying the frontend
Section titled “Deploying the frontend”plasma doesn’t dictate a frontend host. The Quick Start uses Vite; you can deploy that to:
- Cloudflare Workers Static Assets (recommended for
same-domain deploys — no CORS on
/sync) - Vercel or Netlify with the frontend on a different domain and CORS enabled on the Worker
- The same Worker’s
assetsbinding if you build the frontend intodist/and point wrangler at it
Local development
Section titled “Local development”wrangler dev runs the Worker against Miniflare-emulated D1 and
R2 locally:
wrangler dev --localYour frontend’s endpoint can point at http://localhost:8787/sync
during development.
Production checklist
Section titled “Production checklist”-
wrangler.jsonchas D1, R2, DO bindings correctly named -
SCHEMA_VERSIONmatches between client and server exports -
auth()validates real tokens, not just returns{ok: true} -
endpointin client is HTTPS and points at the deployed Worker or its custom domain -
runMigrationsruns at deploy time -
scheduledhandler forgcOrphanedBlobsif you usefile() - Frontend build is on the same domain (
/sync) or CORS is configured on the Worker - Observability enabled in
wrangler.jsonc
What to read next
Section titled “What to read next”- Migrations — the DDL-diff runtime that runs at deploy
- Files and blobs — R2 bucket sizing and lifecycle
- Auth and permissions — writing a
production
auth()handler - Multi-region — scaling past a single D1