Skip to content

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 wrangler logged 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=cosine

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

Put the public values in .env so they are built into the client:

VITE_CLERK_PUBLISHABLE_KEY=pk_live_...
VITE_SITE_URL=https://margin.example.com

Every 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 deploy

This 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#

StepCommand
Type-checkpnpm check
Check notespnpm notes:check
Set the site URLVITE_SITE_URL in .env
Migratepnpm db:migrate:remote
Deploypnpm deploy
ReindexPOST /api/admin/reindex

Search documentation

Search every page of the Margin docs