Deploying SonicJS to Cloudflare Workers: A Step-by-Step Guide
Ship a SonicJS headless CMS to Cloudflare Workers in minutes โ wrangler config, D1, KV, R2, secrets, custom domains, preview deploys, and rollback in one guide.

Deploying SonicJS to Cloudflare Workers: A Step-by-Step Guide
TL;DR โ SonicJS is built for Cloudflare Workers, so deployment is essentially three commands: create the D1, KV, and R2 bindings, drop the IDs into wrangler.toml, and run npm run deploy. The rest of this guide is the production polish โ secrets, environments, preview URLs, custom domains, and rollback.
Key Stats:
- 300+ Cloudflare edge locations serve your CMS globally on day one
- 0 ms cold start โ Workers are warm everywhere, all the time
- 1 command to deploy:
npm run deploy(which runswrangler deploy) - 100,000 free Worker requests / day on the free tier; 5 GB free D1 storage
- Sub-50 ms response times for content reads worldwide
You can build the most beautiful CMS in the world, but if shipping it requires a 40-step runbook, your team will dread every release. SonicJS was designed in the opposite direction: the deploy story is short, predictable, and almost entirely declarative. This guide walks through the whole thing โ from a fresh project to a production deployment on a custom domain โ using only the commands SonicJS actually ships with.
By the end, you'll have:
- A
wrangler.tomlwired up to D1, KV, and R2 - Secrets managed with
wrangler secret - Preview and production environments
- A custom domain with automatic SSL
- A working rollback procedure
If you haven't created a project yet, start with the Getting Started guide and meet back here when npm run dev is humming on localhost:8787.
Why Deploy SonicJS to Cloudflare Workers?
Most CMS deployments end with "now provision a server, set up Nginx, configure systemd, and add a CDN in front of it." SonicJS skips all of that because it's built for the edge from the ground up.
When you wrangler deploy, your code is pushed to 300+ Cloudflare data centers simultaneously. There is no origin server. There is no autoscaling group. The Worker runtime is always warm โ V8 isolates spin up in microseconds, so cold starts effectively don't exist.
The data layer is just as boring (in the best way):
- D1 โ SQLite at the edge, replicated globally, sub-millisecond reads on hot rows
- KV โ eventually-consistent key-value for cached content and JWT verification
- R2 โ S3-compatible object storage with zero egress fees, perfect for media
You bind these services in one wrangler.toml file. SonicJS reads them from env at runtime. No ORM connection strings, no S3 credentials in environment variables, no firewall rules. The platform handles the wiring.
For the architectural backstory, see why edge-first CMS is the future.
Prerequisites
Before you deploy, make sure you have:
- Node.js 20+ and npm
- Wrangler CLI v4 or later (
npm install -g wrangler) - A Cloudflare account โ the free tier is enough to ship; D1, KV, and R2 all have generous free allowances
- A working SonicJS project (
npm create sonicjs-app my-cms)
# Verify the prerequisites
node --version # v20+
wrangler --version # 4.52.x or later
wrangler login # opens a browser, saves credentials locally
wrangler whoami # confirms which account you're targeting
If wrangler whoami shows the wrong account (you have multiple), set the account explicitly in wrangler.toml:
account_id = "your-cloudflare-account-id"
Step 1: Anatomy of a SonicJS wrangler.toml
The starter template ships with a wrangler.toml that's already pointed at the right migrations directory. Here's what you'll work with:
name = "my-sonicjs-app"
main = "src/index.ts"
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]
# Cloudflare Workers settings
workers_dev = true
# D1 Database
[[d1_databases]]
binding = "DB"
database_name = "my-sonicjs-db"
database_id = "YOUR_DATABASE_ID" # filled in next step
migrations_dir = "./node_modules/@sonicjs-cms/core/migrations"
# R2 Bucket for media storage
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "my-sonicjs-media"
# Environment variables
[vars]
ENVIRONMENT = "development"
CORS_ORIGINS = "http://localhost:8787"
# Production environment
[env.production]
name = "my-sonicjs-app-production"
vars = { ENVIRONMENT = "production" }
# Observability
[observability]
enabled = true
Three things to notice:
compatibility_flags = ["nodejs_compat"]โ required so SonicJS can use Node-style crypto and buffer APIs.migrations_dirpoints intonode_modulesโ SonicJS ships its core schema migrations with the package, so you get fresh schema on everynpm install.[observability]โ enables Cloudflare's built-in request logs and metrics. Leave it on; it's free.
Step 2: Create the D1 Database
D1 is SonicJS's primary data store. Create the production database with one command:
wrangler d1 create my-sonicjs-db
The CLI prints the binding stanza โ copy the database_id into your wrangler.toml:
[[d1_databases]]
binding = "DB"
database_name = "my-sonicjs-db"
database_id = "583c089c-1a4a-477d-9d58-06c07bf7c1d7"
migrations_dir = "./node_modules/@sonicjs-cms/core/migrations"
Now apply the SonicJS schema. The starter project ships with these npm scripts:
# Apply migrations to the local SQLite copy (used by `wrangler dev`)
npm run db:migrate:local
# Apply migrations to the remote D1 database
npm run db:migrate
Under the hood these run wrangler d1 migrations apply DB with and without --local. After the remote apply, sanity-check the tables exist:
wrangler d1 execute my-sonicjs-db \
--command="SELECT name FROM sqlite_master WHERE type='table';"
You should see users, collections, content, content_versions, media, api_tokens, and the plugin tables. For a deeper look at the schema and how Drizzle generates migrations, read the database documentation.
Step 3: Create the KV Namespace
KV holds SonicJS's edge cache โ API responses, collection metadata, and verified JWTs. Create the namespace:
wrangler kv namespace create CACHE_KV
Wrangler returns an id. Add it to wrangler.toml:
[[kv_namespaces]]
binding = "CACHE_KV"
id = "a16f8246fc294d809c90b0fb2df6d363"
If you want a separate preview namespace for wrangler dev (recommended so dev traffic doesn't pollute production cache), create one with --preview and add the preview_id:
wrangler kv namespace create CACHE_KV --preview
The cache plugin uses keys like cache:api:collections:all and cache:api:content-list:limit:50. You can list them later with wrangler kv key list --binding CACHE_KV. For caching internals, see the caching strategy guide.
Step 4: Create the R2 Bucket
R2 stores uploaded media โ images, PDFs, video. Cloudflare charges for storage but never for egress, so it's a great fit for a public-facing CMS.
wrangler r2 bucket create my-sonicjs-media
The starter wrangler.toml already has the binding:
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "my-sonicjs-media"
Verify the bucket exists:
wrangler r2 bucket list
If you want media URLs to look like https://media.yourdomain.com/uploads/... instead of the raw R2 URL, attach a custom domain from the Cloudflare dashboard: R2 โ your-bucket โ Settings โ Connect Domain. Cloudflare creates the DNS records automatically.
Step 5: Manage Secrets the Right Way
There are two kinds of configuration in Cloudflare Workers:
- Public variables โ go in
[vars]inwrangler.toml, are visible to anyone with read access to the repo. - Secrets โ encrypted at rest, only readable by your Worker at runtime.
Anything sensitive โ JWT signing keys, OAuth client secrets, email API keys โ must be a secret. Use wrangler secret put:
# JWT signing key (generate a strong one)
openssl rand -base64 32 | wrangler secret put JWT_SECRET
# OAuth credentials (if using the OAuth plugin)
echo "Iv1.xxxxxxxx" | wrangler secret put GITHUB_CLIENT_ID
echo "ghp_xxxxxxxx" | wrangler secret put GITHUB_CLIENT_SECRET
# Email provider for magic link / OTP
echo "your-resend-api-key" | wrangler secret put RESEND_API_KEY
List existing secrets (names only โ values are never displayed):
wrangler secret list
For environment-specific secrets, append --env production:
echo "$(openssl rand -base64 32)" | wrangler secret put JWT_SECRET --env production
If you're using the OAuth, magic link, or OTP plugins, the authentication guide walks through every secret each plugin expects.
Step 6: Public Variables and Environments
The non-secret config โ environment label, CORS allowlist, feature flags โ lives in wrangler.toml. The starter splits dev and production cleanly:
# Default (local dev) vars
[vars]
ENVIRONMENT = "development"
CORS_ORIGINS = "http://localhost:8787"
# Production environment
[env.production]
name = "my-sonicjs-app-production"
vars = { ENVIRONMENT = "production", CORS_ORIGINS = "https://yourdomain.com" }
To deploy against [env.production], pass --env production:
wrangler deploy --env production
ENVIRONMENT=production does real work in SonicJS:
- Cookies are flagged
Secure(HTTPS only) - Verbose error stack traces are stripped from API responses
- Stricter rate-limit defaults are applied
Always set CORS_ORIGINS to an explicit allowlist in production. Never * if you rely on cookie-based auth โ the security overview explains why.
Step 7: Your First Deploy
With bindings and secrets in place, you're ready to ship:
npm run deploy
That runs wrangler deploy in the background. The output looks like this:
โจ Compiled Worker successfully
Total Upload: 1450.2 KiB / gzip: 312.6 KiB
Uploaded my-sonicjs-app (3.21 sec)
Deployed my-sonicjs-app triggers (0.85 sec)
https://my-sonicjs-app.your-subdomain.workers.dev
Current Version ID: 7c9d4b2e-1234-5678-90ab-cdef12345678
Open the URL. You should see the SonicJS admin login. Hit the health endpoint to confirm everything is wired up:
curl https://my-sonicjs-app.your-subdomain.workers.dev/api/health
# { "status": "healthy", "schemas": ["users", "collections", "content", ...] }
If the health check returns unhealthy, the most common cause is missing migrations on the remote D1. Run npm run db:migrate again and retry.
Step 8: Preview Deployments
You don't want to deploy untested changes straight to your main URL. Cloudflare's preview deployment model is dead simple โ every named environment gets its own URL.
Add a preview environment to wrangler.toml:
[env.preview]
name = "my-sonicjs-app-preview"
vars = { ENVIRONMENT = "preview", CORS_ORIGINS = "https://preview.yourdomain.com" }
[[env.preview.d1_databases]]
binding = "DB"
database_name = "my-sonicjs-db-preview"
database_id = "preview-database-id"
[[env.preview.kv_namespaces]]
binding = "CACHE_KV"
id = "preview-kv-namespace-id"
[[env.preview.r2_buckets]]
binding = "BUCKET"
bucket_name = "my-sonicjs-media-preview"
Create the preview-flavored bindings (separate D1, separate KV, separate R2 โ never share data with production):
wrangler d1 create my-sonicjs-db-preview
wrangler kv namespace create CACHE_KV --env preview
wrangler r2 bucket create my-sonicjs-media-preview
Then deploy:
wrangler deploy --env preview
Preview is now reachable at https://my-sonicjs-app-preview.your-subdomain.workers.dev. Promote to production when you're satisfied:
wrangler deploy --env production
Step 9: Connect a Custom Domain
The workers.dev URL is fine for testing, but production wants a real domain. There are two ways to wire one up:
Option A: Workers Custom Domain (recommended)
The simplest path. From the dashboard:
- Workers & Pages โ my-sonicjs-app-production โ Triggers
- Add Custom Domain โ
cms.yourdomain.com
Cloudflare creates the DNS record, provisions an SSL cert, and routes traffic. Done.
Option B: Routes via wrangler.toml
If you want to declare routes in code (great for IaC workflows), use the routes array:
[env.production]
name = "my-sonicjs-app-production"
routes = [
{ pattern = "cms.yourdomain.com/*", zone_name = "yourdomain.com" }
]
Then wrangler deploy --env production will register the route. Either way, SSL is automatic โ Cloudflare's edge certificates auto-renew with no action from you.
Verify HTTPS is live:
curl -I https://cms.yourdomain.com/api/health
Step 10: Monitoring and Observability
Once you're live, you want eyes on the system. SonicJS deployments inherit two free monitoring layers:
Real-Time Logs
Stream live request logs straight to your terminal:
wrangler tail --env production
# Filter to errors only
wrangler tail --env production --status error
# Filter to a single endpoint by sampling
wrangler tail --env production --sampling-rate 0.1
This is invaluable during a deploy โ keep tail open in a second window so you spot regressions immediately.
Cloudflare Analytics
Because you set [observability] enabled = true in wrangler.toml, every request is graphed:
- Workers & Pages โ my-sonicjs-app-production โ Analytics
- Watch P50, P95, P99 latencies, error rate, CPU time, request volume
If you want long-term retention, hook up Logpush (S3, R2, Datadog, etc.) or drop Sentry into your Worker. Both options are documented in the deployment runbook.
Step 11: Rollback in 30 Seconds
The worst deploy is the one you can't reverse. Wrangler ships with a one-command rollback:
# See what's deployed
wrangler deployments list --env production
# Roll back to the previous version
wrangler rollback --env production
# Roll back to a specific deployment ID
wrangler rollback --env production --deployment-id abc123def456
Your Worker reverts to the prior version in seconds โ no rebuild, no redeploy. Note that rollback only reverts code, not the database. If your bad deploy ran a migration, you'll need to manually reverse it (D1 doesn't support automatic down migrations).
The safest pattern: take a backup before every production deploy.
wrangler d1 export my-sonicjs-db \
--output "backup-$(date +%Y%m%d-%H%M%S).sql"
Stash it in R2 or S3 and you have a panic button.
A Production Hardening Checklist
Before you tell the world, run through this list:
-
ENVIRONMENT=productionis set in[env.production].vars -
CORS_ORIGINSis an explicit allowlist (no*) -
JWT_SECRETis a 256-bit value, set viawrangler secret put, unique per env - All migrations applied:
wrangler d1 migrations list DB --env production - Initial admin user seeded (or first registration completed)
- Custom domain attached, SSL active, HTTPS enforced
- Health check returns
healthy:curl https://cms.yourdomain.com/api/health -
wrangler tailclean for at least 5 minutes after deploy - D1 backup exported and stored off-platform
- Rate limiting and CSRF middleware applied to state-changing routes (see security)
Hit all ten and you're production-ready.
Troubleshooting Common Issues
D1_ERROR: no such table: users โ Migrations haven't run on the remote DB. Run npm run db:migrate (which is wrangler d1 migrations apply DB).
JWT_SECRET is not defined โ Secret missing in this environment. Confirm with wrangler secret list --env production, then echo "..." | wrangler secret put JWT_SECRET --env production.
R2 bucket 'my-sonicjs-media' not found โ Bucket binding mismatch. Run wrangler r2 bucket list and verify the name in wrangler.toml matches.
KV namespace binding 'CACHE_KV' not found โ Same problem as R2. List with wrangler kv namespace list and double-check the id.
Worker exceeds the 10 MB free-tier size limit โ Check your bundle: wrangler deploy --dry-run --outdir dist. Most often it's a forgotten dev dependency. Move it to devDependencies and re-deploy.
Cannot find package '@sonicjs-cms/core' โ Either you skipped npm install or your main path in wrangler.toml is wrong. The starter expects main = "src/index.ts".
For more deployment-specific issues, the configuration guide documents every env var SonicJS reads.
Putting It All Together
For reference, here's the production-ready wrangler.toml you should end up with:
name = "my-sonicjs-app"
main = "src/index.ts"
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]
workers_dev = true
[[d1_databases]]
binding = "DB"
database_name = "my-sonicjs-db"
database_id = "your-d1-id"
migrations_dir = "./node_modules/@sonicjs-cms/core/migrations"
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "my-sonicjs-media"
[[kv_namespaces]]
binding = "CACHE_KV"
id = "your-kv-id"
[vars]
ENVIRONMENT = "development"
CORS_ORIGINS = "http://localhost:8787"
[observability]
enabled = true
# โโโ Production โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
[env.production]
name = "my-sonicjs-app-production"
routes = [
{ pattern = "cms.yourdomain.com/*", zone_name = "yourdomain.com" }
]
vars = { ENVIRONMENT = "production", CORS_ORIGINS = "https://yourdomain.com" }
[[env.production.d1_databases]]
binding = "DB"
database_name = "my-sonicjs-db"
database_id = "your-d1-id"
migrations_dir = "./node_modules/@sonicjs-cms/core/migrations"
[[env.production.r2_buckets]]
binding = "BUCKET"
bucket_name = "my-sonicjs-media"
[[env.production.kv_namespaces]]
binding = "CACHE_KV"
id = "your-kv-id"
And the deploy command stays exactly what it was on day one:
npm run deploy # to default env
wrangler deploy --env production # to production
That's the entire deploy story.
Next Steps
You've shipped a SonicJS CMS to the edge. Where to go from here:
- Database guide โ schema design, migrations, and Drizzle ORM patterns
- Caching strategy โ the three-layer cache that keeps SonicJS fast at scale
- Authentication โ wire up password, OAuth, magic link, and OTP login
- Security overview โ production CORS, CSRF, rate limiting, password hashing
- Configuration reference โ every env var, binding, and plugin option
Key Takeaways
- One config file drives everything:
wrangler.tomldeclares D1, KV, R2, env vars, and routes. - One command ships to global edge:
npm run deployorwrangler deploy --env production. - Secrets stay encrypted via
wrangler secret putโ never in the repo, never in[vars]. - Preview environments are first-class โ separate bindings, separate URL, same code.
- Rollback is one command (
wrangler rollback), so deploying with confidence costs nothing. - Cloudflare's free tier covers most early-stage projects โ 100k requests/day, 5 GB D1, free R2 egress.
Have questions, or shipped something cool with SonicJS? Join us on Discord or GitHub โ we'd love to hear about it.
Happy shipping!
Related Articles

File Uploads with SonicJS and Cloudflare R2
Upload, validate, and serve images, video, and documents with SonicJS and Cloudflare R2 โ multipart uploads, MIME checks, signed URLs, and image transforms.

How to Build a Blog with SonicJS and Cloudflare Workers
Step-by-step tutorial on building a blazingly fast blog using SonicJS headless CMS deployed on Cloudflare Workers with D1 database and R2 storage.

Getting Started with SonicJS: Complete Beginner's Guide
Learn how to set up SonicJS, the edge-first headless CMS for Cloudflare Workers. This comprehensive guide covers installation, configuration, and your first content API.