Luqman Hakim

This post covers production bindings, secrets, migrate-before-deploy, and a GitHub Actions workflow that typechecks, migrates D1, deploys the Worker, and smoke-checks /health.

Prerequisites

  • A SonicJS project that already runs locally with wrangler dev
  • Cloudflare account + Wrangler logged in once (npx wrangler login)
  • D1 database and R2 bucket created (or create them now)
npx wrangler d1 create my-cms-db
npx wrangler r2 bucket create my-cms-media

Enter fullscreen mode Exit fullscreen mode

Paste the returned database_id into wrangler.toml.

Production env in wrangler.toml

Keep a default (local) section and a dedicated [env.production] block. Same bindings, production vars:

name = "my-cms"
main = "src/index.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding = "DB"
database_name = "my-cms-db"
database_id = "YOUR_DATABASE_ID"
migrations_dir = "./node_modules/@sonicjs-cms/core/migrations"

[[r2_buckets]]
binding = "MEDIA_BUCKET"
bucket_name = "my-cms-media"

[vars]
ENVIRONMENT = "development"
CORS_ORIGINS = "http://localhost:8787"
BUCKET_NAME = "my-cms-media"

[env.production]
name = "my-cms"
vars = { ENVIRONMENT = "production", CORS_ORIGINS = "https://my-cms.YOUR_SUBDOMAIN.workers.dev", BUCKET_NAME = "my-cms-media" }

[[env.production.d1_databases]]
binding = "DB"
database_name = "my-cms-db"
database_id = "YOUR_DATABASE_ID"
migrations_dir = "./node_modules/@sonicjs-cms/core/migrations"
remote = true

[[env.production.r2_buckets]]
binding = "MEDIA_BUCKET"
bucket_name = "my-cms-media"

Enter fullscreen mode Exit fullscreen mode

Notes:

  • remote = true on the production D1 binding helps local scripts (seed:prod, password reset) talk to real D1 via getPlatformProxy. Deploy itself ignores that flag.
  • Put CORS origins in vars so your frontend can call the public API.
  • Never put secrets in [vars] — those end up in the Worker bundle metadata.

Secrets (auth won’t work without them)

SonicJS / Better Auth need secrets at runtime:

openssl rand -hex 32 | npx wrangler secret put BETTER_AUTH_SECRET --env production
openssl rand -hex 32 | npx wrangler secret put JWT_SECRET --env production

Enter fullscreen mode Exit fullscreen mode

Rotate the same way later. Treat these like production passwords.

Manual deploy: migrate, then ship

npm scripts that match the mental model:

{
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy --env production",
    "db:migrate": "wrangler d1 migrations apply DB --remote --env production",
    "db:migrate:local": "wrangler d1 migrations apply DB --local"
  }
}

Enter fullscreen mode Exit fullscreen mode

Always migrate before deploying code that expects new tables/columns:

npm run db:migrate
npm run deploy

Enter fullscreen mode Exit fullscreen mode

Order matters. Ship Worker code that queries a column D1 doesn’t have yet, and you’ll debug “works locally, 500 in prod” for an afternoon.

Bootstrap the first admin (once)

Seeding should never store a password in git. Pass credentials via env:

ADMIN_EMAIL=[email protected] ADMIN_PASSWORD='your-secure-password' npm run seed:prod

Enter fullscreen mode Exit fullscreen mode

(Implementation detail in Part 3 — the script uses Wrangler’s platform proxy against production D1.)

GitHub Actions: migrate + deploy + smoke

Goal on every push to main:

  1. Install + typecheck
  2. Apply remote D1 migrations
  3. Deploy Worker (--env production)
  4. curl /health until it succeeds

1. Cloudflare API token

Dashboard → My ProfileAPI Tokens → customize Edit Cloudflare Workers with at least:

  • Account → Workers Scripts → Edit
  • Account → Workers R2 Storage → Edit
  • Account → D1 → Edit
  • Account → Account Settings → Read

2. GitHub Environment production

Repo → SettingsEnvironments → create production:

Type Name Value
Secret CLOUDFLARE_API_TOKEN token from step 1
Secret CLOUDFLARE_ACCOUNT_ID your account id
Variable WORKER_URL https://my-cms.YOUR_SUBDOMAIN.workers.dev

Using a GitHub Environment keeps prod credentials scoped and lets you add required reviewers later.

3. Workflow

name: Deploy Worker

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  deploy:
    name: Migrate & deploy
    runs-on: ubuntu-latest
    timeout-minutes: 15
    environment: production
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      # Prefer npm install if npm ci fails on optional platform binaries
      # (some lockfiles list every @esbuild/* optional package).
      - name: Install dependencies
        run: npm install --no-audit --no-fund

      - name: Typecheck
        run: npm run type-check

      - name: Apply D1 migrations
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          wranglerVersion: "4.107.1"
          command: d1 migrations apply DB --remote --env production

      - name: Deploy Worker
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          wranglerVersion: "4.107.1"
          command: deploy --env production

      - name: Smoke check
        env:
          WORKER_URL: ${{ vars.WORKER_URL }}
        run: |
          URL="${WORKER_URL:?Set WORKER_URL in the production environment}"
          curl -fsS --retry 5 --retry-delay 3 "$URL/health"
          echo
          echo "Health check OK ($URL)"

Enter fullscreen mode Exit fullscreen mode

Pin wranglerVersion to what you use locally so CI and laptops don’t diverge.

concurrency.cancel-in-progress: false matters for deploys: you don’t want a second push to cancel a migration mid-flight.

Separate CI for PRs

Keep PRs cheap — typecheck only, no production migrate/deploy:

name: CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm install --no-audit --no-fund
      - run: npm run type-check

Enter fullscreen mode Exit fullscreen mode

Checklist after first green deploy

  • [ ] GET $WORKER_URL/health returns OK
  • [ ] Secrets set (BETTER_AUTH_SECRET, JWT_SECRET)
  • [ ] Admin seeded
  • [ ] Can open /auth/login and /admin
  • [ ] Frontend CORS origin is in production CORS_ORIGINS
  • [ ] Media upload hits R2 (create a post with a featured image)

What’s next

Deploy is the easy win. The painful bugs show up in auth: disabled signup, Better Auth credential rows vs profile password hashes, and RBAC so editors can actually open the portal.