Skip to content
Development

The Cloudflare Workers Builds deploy that failed on a config file arguing with itself

By Victor Da Luz
cloudflareciastrodev-logsite

GitHub Actions stopped working for this site a few weeks back. Billing lapsed on the account, and I decided not to fix that by paying for it. The repo is private, so Actions was never coming back for free. That left the site’s only deploy path broken: every push to main built fine locally but never reached production, because the CI job that ran wrangler deploy just never started.

The fix everyone points you to is Cloudflare Workers Builds. It’s Cloudflare’s own Git-connected CI for Workers, no GitHub Actions minutes needed. Connect the repo, set a build command and a deploy command, done. I wrote it up as a plan, deleted the two dead workflow files, documented the new flow, merged it. Then I asked the obvious next question: does it actually deploy?

The first failure looked like it should’ve been my fault

I’d tested the deploy command locally before writing the dashboard instructions: npx wrangler deploy dist/server/entry.mjs --dry-run ran clean. So I told the dashboard to use npx wrangler deploy dist/server/entry.mjs as the deploy command and called it done.

The build failed. All I had was a screenshot: “Failed: error occurred while running deploy command.” No detail. I asked for the log lines above that, got a screenshot of the log scrolled to the wrong spot, asked again, and at that point I said the thing that actually moved this forward: copy-pasting logs back and forth is caveman shit, give me real access.

Getting real access meant learning two things about Cloudflare tokens

The obvious move was to mint a fresh, narrowly-scoped API token. My first instinct was to scope it to “this one Worker.” That was wrong on two counts. First, the Cloudflare account here already spans four sites, not one, so “the account” was never a narrow scope to begin with. Second, and more useful: there was already a working deployment token sitting in the homelab’s Ansible vault, used for exactly this kind of thing. Minting a new token before checking what already existed was the actual mistake, not the scope I picked.

I pulled the existing token and tested it. It worked fine for reading Workers Scripts across all four sites. It failed outright against the Workers Builds API with a bare “Authentication error.” Turns out Cloudflare draws a real line here: the Builds API only accepts user tokens, ones a human creates through their own profile page, not account-owned tokens minted for a service account or CI pipeline. Try the other kind and you get rejected before permissions even come into it. Two different tokens, two different jobs, and no way to make one do both.

So I needed a second token, one created by hand, scoped to Workers Builds Configuration and Workers Scripts read access. Once I had it, storing it became its own small detour. I wanted it in the vault next to the existing token, same place, one pattern. But decrypting a shared vault to add one line means the whole file sits in plaintext on disk for a moment, and my own safety net (correctly) didn’t like that happening as a side effect of debugging a deploy script. I didn’t fight it. I dropped the token in its own chmod 600 file instead, same shape as a credential I already use for a different tool, and moved on. The vault consolidation is still a good idea; it just isn’t today’s problem.

What the logs actually said

With the right token, the Workers Builds REST API is straightforward: list builds for a Worker by its tag (not its name, a different field), grab a build UUID, fetch its logs. The failed build’s log ended with this:

Executing user deploy command: npx wrangler deploy dist/server/entry.mjs

✘ [ERROR] Found both a user configuration file at "dist/server/wrangler.json"
  and a deploy configuration file at ".wrangler/deploy/config.json".
  But these do not share the same base path so it is not clear which should be used.

Failed: error occurred while running deploy command

@astrojs/cloudflare writes its own wrangler.json into the server build output. It’s a real file, generated fresh every build, describing the entry point and bindings. Workers Builds separately stages its own deploy config in a different location. Point wrangler at the entry file with no explicit --config flag, and it finds two candidate configs at two different paths and refuses to guess between them. This never showed up in my local dry-run, because a plain checkout never has that second staged config file sitting around. It only exists inside the Workers Builds environment itself.

The fix was one flag: npx wrangler deploy --config wrangler.toml dist/server/entry.mjs. The old, dead CI workflow had that flag all along. I dropped it when I wrote the new dashboard instructions from memory instead of copying the working command.

Verifying it for real, not just believing it

Fixing the dashboard setting wasn’t enough on its own; I wanted to see a build actually go green before calling it done. The Builds API has an endpoint to trigger a fresh build directly, so I fired one manually against the corrected trigger and polled it until it stopped. Status: success. New deployment showed up in wrangler deployments list, site still returned 200.

That would have been enough for most fixes, but I wanted the real path proven too, not just the manual one. So I pushed a second, genuine commit (a docs correction recording the fix) and watched the build log for that push specifically. Its trigger metadata said build_trigger_source: push_event, tied to that exact commit hash. That’s the actual thing the issue asked for: a normal git push to main, no manual trigger, ending in a live deploy, with no GitHub Actions anywhere in the chain.

I also found a second, smaller bug for free while poking at all this: the “deploy non-production branches” trigger I’d turned on as a nice-to-have was failing on every Dependabot PR branch, trying to auto-provision a KV namespace that already existed for production. Different root cause, same theme: the config didn’t pin something explicit that Cloudflare’s tooling needed pinned. That one’s a real fix (bind an explicit namespace ID), just not one that blocks anything today, so I left it disabled and logged it as a follow-up instead of chasing it while the actual issue was still open.

What I’d do differently

Copy the working command instead of reconstructing it from a dry-run test that happened to pass for unrelated reasons. A dry-run proves the config resolves; it doesn’t prove the deploy step runs in the same environment it’ll actually run in. And before minting any new credential, check what’s already sitting in the place credentials are supposed to live. I had the right instinct eventually, just not on the first pass.

Related reading