Skip to content

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.

  • Cloudflare account with Workers enabled
  • wrangler CLI 3+ installed (pnpm add -g wrangler)
  • wrangler login completed
  • Node.js 20+ locally
{
"$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 — where file() column bytes live.
  • durable_objects.COORDINATOR — for realtime WebSocket poke fan-out. Optional if you’re happy with polling-only.
Terminal window
wrangler d1 create my-todo-app-prod
# Paste the printed database_id into wrangler.jsonc
wrangler r2 bucket create my-todo-app-blobs
worker.ts
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 aren’t automatic. Two patterns:

// Add to worker.ts fetch dispatch
if (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:

Terminal window
wrangler deploy
curl -X POST -H "x-admin-token: $ADMIN_TOKEN" https://my-app.workers.dev/admin/migrate

Pattern 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.

Terminal window
wrangler deploy

The 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.

wrangler.jsonc
{
"vars": {
"PUBLIC_KEY": "..."
}
}

Secrets (like ADMIN_TOKEN, JWT signing keys):

Terminal window
wrangler secret put ADMIN_TOKEN
wrangler secret put JWT_SIGNING_KEY

Access 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": { "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-side onError handler if you wire it to console.error
  • blob-read-auth-cap-hit — surfaces when readAuthMaxRefs was 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.

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 assets binding if you build the frontend into dist/ and point wrangler at it

wrangler dev runs the Worker against Miniflare-emulated D1 and R2 locally:

Terminal window
wrangler dev --local

Your frontend’s endpoint can point at http://localhost:8787/sync during development.

  • wrangler.jsonc has D1, R2, DO bindings correctly named
  • SCHEMA_VERSION matches between client and server exports
  • auth() validates real tokens, not just returns {ok: true}
  • endpoint in client is HTTPS and points at the deployed Worker or its custom domain
  • runMigrations runs at deploy time
  • scheduled handler for gcOrphanedBlobs if you use file()
  • Frontend build is on the same domain (/sync) or CORS is configured on the Worker
  • Observability enabled in wrangler.jsonc