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
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"
Notes:
-
remote = trueon the production D1 binding helps local scripts (seed:prod, password reset) talk to real D1 viagetPlatformProxy. 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
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"
}
}
Always migrate before deploying code that expects new tables/columns:
npm run db:migrate
npm run deploy
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=you@example.com ADMIN_PASSWORD='your-secure-password' npm run seed:prod
(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:
- Install + typecheck
- Apply remote D1 migrations
- Deploy Worker (
--env production) -
curl/healthuntil it succeeds
1. Cloudflare API token
Dashboard → My Profile → API 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 → Settings → Environments → 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)"
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
Checklist after first green deploy
- [ ]
GET $WORKER_URL/healthreturns OK - [ ] Secrets set (
BETTER_AUTH_SECRET,JWT_SECRET) - [ ] Admin seeded
- [ ] Can open
/auth/loginand/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.
Top comments (0)