Deploying to Cloudflare
Create the Cloudflare resources, set secrets, deploy the Worker and build the search index.
Margin deploys as a single Cloudflare Worker that serves the app, the API and the server functions. It uses D1 for user data, KV (development-only rate limits and test accounts), Vectorize and Workers AI for semantic search, and an R2 bucket reserved for uploads.
Before you start#
- A Cloudflare account, with
wranglerlogged in:pnpm wrangler login - Optional: a Clerk production application, for accounts
Create the resources#
Create each resource once:
pnpm wrangler d1 create alevel-maths
pnpm wrangler kv namespace create KV
pnpm wrangler r2 bucket create alevel-maths-uploads
pnpm wrangler vectorize create alevel-maths-notes --dimensions=768 --metric=cosineThe names come from before the project was called Margin. They are only identifiers, so you can keep them; if you rename them, change wrangler.jsonc and the db:migrate scripts in package.json to match.
Each command prints an id. Paste the D1 database_id and the KV id into wrangler.jsonc:
{
"d1_databases": [
{ "binding": "DB", "database_name": "alevel-maths", "database_id": "<your D1 id>", "migrations_dir": "drizzle" }
],
"kv_namespaces": [{ "binding": "KV", "id": "<your KV id>" }],
"r2_buckets": [{ "binding": "UPLOADS", "bucket_name": "alevel-maths-uploads" }],
"vectorize": [{ "binding": "VECTORS", "index_name": "alevel-maths-notes" }],
"ai": { "binding": "AI" }
}The Vectorize index must have 768 dimensions to match the embedding model, @cf/baai/bge-base-en-v1.5.
Migrate and set secrets#
Apply the database migrations, then set each secret you need:
pnpm db:migrate:remote
pnpm wrangler secret put CLERK_SECRET_KEY
pnpm wrangler secret put ADMIN_TOKENPut the public values in .env so they are built into the client:
VITE_CLERK_PUBLISHABLE_KEY=pk_live_...
VITE_SITE_URL=https://margin.example.comEvery secret is optional. Without the Clerk keys, accounts are off. The AI features are switched off in code (AI_ENABLED in src/config/ai.ts), so a deployed site has none, and there are no AI secrets to set. The migrations include 0003_profiles.sql, which stores onboarding answers. Set up the Clerk instance as described in Accounts with Clerk. VITE_SITE_URL gives social previews absolute image URLs, so set it for any public site. See the Configuration reference for every variable.
Deploy#
pnpm deployThis runs the production build and wrangler deploy. The build fails if server-only code has leaked into a client bundle, so a successful build is safe to ship.
Build the semantic index#
After the first deploy, and whenever notes change, rebuild the semantic search index:
curl -X POST https://<your-worker>/api/admin/reindex \
-H 'content-type: application/json' \
-d '{"token":"<ADMIN_TOKEN>"}'The response reports how many notes and chunks were indexed:
{ "notes": 120, "chunks": 1840 }A wrong token returns 403. Lexical search works without the index; semantic results simply do not appear until it is built.
Updating#
To ship changes, run pnpm deploy again. If the change includes a new migration in drizzle/, run pnpm db:migrate:remote first. If notes changed, call the reindex endpoint afterwards.
Checklist#
| Step | Command |
|---|---|
| Type-check | pnpm check |
| Check notes | pnpm notes:check |
| Set the site URL | VITE_SITE_URL in .env |
| Migrate | pnpm db:migrate:remote |
| Deploy | pnpm deploy |
| Reindex | POST /api/admin/reindex |