You enabled preview deployments on Cloudflare. You opened a PR. The preview built, deployed, and is now reading and writing your production D1 database. Every user action on that preview URL hits prod data. And the moment you add migrations to your deploy command, those run against prod too.
That's not a hypothetical. That's the default behavior.
The Problem
Cloudflare Workers preview deployments use the top-level bindings in your wrangler.jsonc. There is no magic separation. If your D1 database_id points to production, every preview deployment — including the ones triggered by your half-finished feature branch — is bound to production. Reads, writes, all of it.
The default non-production deploy command (npx wrangler versions upload) won't run migrations on its own. But the preview Worker still connects to your prod database at runtime. And once you customize the deploy command to include migrations (which you will, because you need schema changes to actually work in previews), those migrations run against prod.
The preview_database_id field? That's only for wrangler dev locally. It does nothing for deployed previews.
There's another angle to this. If you're using AI coding agents that open PRs autonomously — background agents, CI bots, whatever — you get a preview deployment URL but it's either pointing at prod or the schema changes the agent made simply don't work because no migration ran. You can't actually verify what the agent built without a working, isolated database behind the preview. This makes the staging environment not just a safety measure, but a prerequisite for any kind of automated PR workflow.
The Fix: Wrangler Environments
Wrangler supports environments — named configurations that deploy as separate Workers with their own bindings. You create a staging environment, point it at a separate D1 database, and tell Cloudflare to use it for non-production deploys.
Step 1: Create staging resources
npx wrangler d1 create my-app-staging
npx wrangler kv namespace create KV_STAGING
Save the returned IDs.
Step 2: Add env.staging to your wrangler config
Here's the critical part. Bindings in Cloudflare are non-inheritable. You can't just override the D1 binding — you must redeclare every single binding inside env.staging. If you forget one, that binding simply won't exist in your staging Worker.
{
"name": "my-app",
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app",
"database_id": "aaaa-bbbb-cccc-prod",
"migrations_dir": "./drizzle/migrations"
}
],
"kv_namespaces": [
{
"binding": "KV",
"id": "aaaa-bbbb-cccc-prod-kv"
}
],
"r2_buckets": [
{
"binding": "R2",
"bucket_name": "my-app"
}
],
"env": {
"staging": {
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-staging",
"database_id": "xxxx-yyyy-zzzz-staging",
"migrations_dir": "./drizzle/migrations"
}
],
"kv_namespaces": [
{
"binding": "KV",
"id": "xxxx-yyyy-zzzz-staging-kv"
}
],
"r2_buckets": [
{
"binding": "R2",
"bucket_name": "my-app"
}
]
}
}
}
Same binding names, different resource IDs. Your application code doesn't change at all.
Step 3: Add staging scripts
{
"scripts": {
"deploy": "pnpm build && wrangler d1 migrations apply DB --remote && wrangler deploy",
"deploy:staging": "CLOUDFLARE_ENV=staging pnpm build && npx wrangler d1 migrations apply DB --remote --env staging && npx wrangler deploy --env staging",
"db:migrate:staging": "npx wrangler d1 migrations apply DB --remote --env staging"
}
}
Step 4: Apply initial migrations to staging
Your staging database is empty. Seed it:
npx wrangler d1 migrations apply DB --remote --env staging
This runs every migration file in your migrations_dir against the staging D1. You only need to do this once — after that, the deploy command handles it.
Step 5: Configure the Cloudflare dashboard
Go to Workers & Pages > your project > Settings > Build and set the Non-production branch deploy command to:
CLOUDFLARE_ENV=staging pnpm run build && npx wrangler d1 migrations apply DB --remote --env staging && npx wrangler versions upload --env staging
That's the whole thing. Every PR push now builds with staging bindings, migrates the staging D1, and uploads an isolated preview.
The CLOUDFLARE_ENV Gotcha
If you're using @cloudflare/vite-plugin (you probably are if you're on React Router v7, Remix, or Astro with Cloudflare), environment selection happens at build time via the CLOUDFLARE_ENV environment variable — not the --env flag.
This means:
-
CLOUDFLARE_ENV=staging pnpm build— builds withenv.stagingbindings -
npx wrangler deploy --env staging— deploys to the staging Worker
You need both. CLOUDFLARE_ENV for the build step, --env for the wrangler commands. Miss the first one and your build bakes in production bindings. Miss the second and wrangler deploys to production.
The Non-Inheritable Trap
This will bite you exactly once: you add a new KV namespace to your top-level config, deploy to production, everything works. Then a PR preview fails because the binding doesn't exist. You forgot to add it to env.staging.
Every time you add or change a binding at the top level, mirror it in env.staging. There is no inheritance. There is no fallback. The staging Worker only sees what you explicitly declare.
That's It
Five steps. One new D1 database, one config block, one dashboard field. Your PR previews now have their own database, your migrations run in isolation, and you can sleep at night knowing a feature branch won't DROP TABLE your users.
Top comments (0)