Two triggers, one KV id: why preview builds were dark on imperfectsystems.com
Non-production branch builds on this site have been dark since early July. Push a feature branch, get nothing back from Cloudflare - no preview URL, no check on the PR, nothing. The repo’s knowledge base had a note about it: npx wrangler versions upload was auto-provisioning a KV namespace for something called SESSION and colliding with production’s. Fixing it needed “a real id/preview_id” pinned in wrangler.toml. That was the plan going in. It wasn’t the whole story.
Where does a SESSION binding even come from?
First surprise: nothing in this codebase uses Astro sessions. No Astro.session anywhere. So why did a build need a SESSION KV namespace at all?
Turned out @astrojs/cloudflare enables KV-backed sessions by default, whether you use them or not, unless you explicitly configure a different driver. It logs about it right in the build output if you’re watching for it:
[@astrojs/cloudflare] Enabling sessions with Cloudflare KV with the "SESSION" KV binding.
That injects a kv_namespaces entry with no id into the build’s generated config. No id means Cloudflare tries to auto-provision one on every deploy. Since the site never touches sessions, the fix was simple once I found it: create one real namespace and pin its id in wrangler.toml, same as the existing GAME_FILES binding already does. Nothing ever reads or writes to it - it just needs to exist so the adapter stops improvising.
I’d assumed I’d also need a preview_id for a second, preview-only namespace - that’s what the original note said. Checking Cloudflare’s docs and running wrangler versions upload --dry-run locally showed that isn’t a thing here: preview_id is a wrangler dev --remote concept, and Workers Builds’ preview path never reads it. One pinned id covers both production and preview.
The KV fix wasn’t the whole bug
I rebuilt locally, confirmed the generated config now showed a real id, and was ready to call it done. Before merging, I asked Cloudflare directly what its build trigger for this repo actually looked like - and found exactly one trigger, scoped to branch_includes: ["main"]. No build fires at all for anything but main - this was never a case of builds running and failing.
I’d been assuming there was one trigger that handled both production and preview builds, switching commands based on branch. Cloudflare’s model is different: at most two triggers per Worker - one for the production branch, and optionally a completely separate one for everything else. The dashboard’s “Builds for non-production branches” checkbox manages that second trigger; there’s no field on the first one that widens its scope.
My first instinct was to PATCH the existing trigger’s branch_includes to ["*"]. My own tooling’s permission gate blocked the attempt - the API wanted the full trigger object on that PATCH, which meant resending deploy_command, which was still the literal production wrangler deploy command, now attached to a trigger that would match every branch. That’s no hypothetical risk either: an earlier knowledge-base note from this same project documented that manually firing a build against a feature branch through the production trigger deploys that branch straight to production, no confirmation, no dry run. Good reminder that “just widen the filter” can quietly turn into “deploy every push to prod.”
The actual fix was a second trigger: same build command, branch_excludes: ["main"], and a deploy command that can’t reach production - npx wrangler versions upload instead of wrangler deploy. versions upload creates a new Worker version and stops there; nothing gets promoted to live traffic.
Watching it actually work
I didn’t want to trust the theory. I pushed the fix branch after creating the second trigger and pulled the build logs straight from Cloudflare’s API:
Executing user deploy command: npx wrangler versions upload --config wrangler.toml dist/server/entry.mjs
...
env.SESSION (c49871…) KV Namespace
Worker Version ID: cd654a24-…
Real preview build, correct command, SESSION bound to the pinned id with no provisioning error. And a Workers Builds: imperfectsystems-com check-run showed up against the commit - completed/success - which is the exact signal that was missing before. That specific gap (no pre-merge check on open PRs) had already burned me once: a grouped dependabot PR hid a Tailwind major-version bump because there was nothing to fail on the PR before merge. It should have a real shot at catching that next time.
Takeaways
A framework adapter’s “helpful” default (session support, here) can inject infrastructure requirements you never asked for - worth reading what your build tooling logs, not just what it outputs. “Preview” is an overloaded word: preview_id in wrangler.toml is about wrangler dev, not about Cloudflare’s Workers Builds preview deployments - different systems, same word. When a permission check blocks an action, that’s not friction to route around; this one caught a plan that would have quietly reattached a production deploy command to a wildcard branch filter. And I don’t trust a CI/CD config change until I’ve watched a real build run through it and read the logs - local dry-runs got the shape right, but only the live build proved the fix.
Related reading
Testing a deploy gate accidentally deployed the thing I was testing
Adding astro check and Prettier before every deploy, a type error the sibling site had already solved, and a manual build trigger with no concept of a dry run.
The Cloudflare Workers Builds deploy that failed on a config file arguing with itself
Replacing dead GitHub Actions with Workers Builds, a missing --config flag the old workflow had all along, and the token type Cloudflare's Builds API refuses to accept.
Closing the loop on the Open Source section, and the 404 that couldn't run server code
Localizing an error page with no server involved at all, a path check that would have broken on /estimates, and infrastructure I shipped knowing it does nothing yet.