Deploy from Zero
Provisioning a repo-based Worker from scratch -- the dashboard-wizard trap, wrangler deploy, .assetsignore, and CI token permissions
Standing up a Worker from an existing repo on a fresh Cloudflare account looks straightforward, but the from-zero path is littered with gotchas that aren't captured in one place anywhere. This page walks the lifecycle in order -- create and deploy the Worker, provision bindings, ship framework build output, then automate deploys with CI -- and flags the trap at each step.
The notes come from provisioning a two-Worker project (an app Worker + a separate docs Worker) from zero. Everything here is general Workers knowledge, not specific to that stack.
Don't Create the Worker from the Dashboard
This is the #1 trap. When your code already lives in a repo, people instinctively reach for the dashboard's "Create a Worker" wizard ("Ship something new" -- import a git repo, pick a template, Hello World, or upload). For a repo-based Worker, every one of those paths is wrong:
The template / Hello World / upload paths create a separate blank Worker that has nothing to do with your repo.
The "import a git repo" path wires up a competing Cloudflare git-build pipeline that fights the CI you actually want to run.
The right path is wrangler deploy from the repo. It is create-and-update in one:
npx wrangler deployFirst deploy: the Worker is born, named after the
namefield in your wrangler config. No dashboard step needed -- deploying a name that doesn't exist yet is how you create the Worker.Later deploys: the same command updates it.
Skip the dashboard wizard entirely
There is no "register the Worker first, then connect the repo" step. The dashboard wizard only produces orphan Workers or a competing build pipeline. wrangler deploy from the repo is the whole provisioning story.
Best long-term setup: let CI run wrangler deploy on push to main, so the committed config is the single source of truth. See Production Deploy for the workflow.
workers.dev URLs Have a Fixed Format
Every Worker gets a free URL of exactly this shape:
<worker-name>.<account-subdomain>.workers.devYou cannot fabricate arbitrary subdomains of it -- doc.app.<account-subdomain>.workers.dev is not a thing. The way to get distinct URLs for an app and its docs is simply two Workers: each gets its own <name>.<account-subdomain>.workers.dev automatically.
Pretty subdomains like app.example.com / doc.example.com require a real custom domain -- a zone in your Cloudflare account -- wired up via routes in the wrangler config (or the dashboard):
{
"routes": [{ "pattern": "app.example.com", "custom_domain": true }]
}Decline Wrangler's "Add It on Your Behalf?" Prompt
Provisioning storage from the CLI (wrangler d1 create, wrangler kv namespace create) ends with an interactive offer:
Would you like Wrangler to add it on your behalf?Decline (N). Saying yes does two bad things to your wrangler config:
It appends a duplicate binding entry under the wrong binding name, leaving the binding your code actually uses (
DB,STATE, ...) stuck on its placeholder id.It reformats the whole file -- 2-space indent becomes tabs, arrays get expanded, the trailing newline is stripped.
Instead, copy the id / database_id that the command printed and paste it into the existing binding by hand:
{
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-db",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
]
}The follow-up prompt "For local dev, connect to the remote resource?" should also be N -- local development against local storage is what you want by default.
Adapters That Emit _worker.js into the Assets Dir
Framework adapters (e.g. @takazudo/zfb-adapter-cloudflare) often write the Worker entry dist/ into the same dist/ directory that is served as static assets. wrangler deploy then hard-errors:
Uploading a Pages _worker.js file as an assetThe fix is a .assetsignore file at the asset-directory root as Wrangler sees it, listing the Worker entry and any statically-imported server modules that land alongside the assets:
_worker.js
_zfb_inner.mjsIf the framework copies a public/ directory into dist/ on every build, put .assetsignore in public/ -- it lands in the deployed assets root automatically, so the fix is durable for both local deploys and CI (instead of being wiped by the next build).
Only adapter-in-assets-dir builds hit this
An app Worker whose main lives outside the assets directory never triggers this error. It's specific to adapters that emit the server entry into the directory registered as assets.directory.
CI Token: "Edit Cloudflare Workers" Is Not Enough
For CI deploys you create an API token, and the dashboard offers an "Edit Cloudflare Workers" token template. The trap: that template does not include D1:Edit. Without it, wrangler d1 migrations apply --remote fails with an authorization error (code: 7403, given account is not valid or is not authorized to access this service).
The same template also omits Vectorize:Read and Workers AI:Read, exactly the way it omits D1:Edit. The symptom looks different, though: a REST assert like GET / fails with a plain HTTP 403 -- no Cloudflare error-code wrapper, unlike D1's 7403. (Vectorize Edit also satisfies this check; Read is enough for a read-only assert.)
Diagnostic rule of thumb (exact codes can drift -- treat the message as the signal):
Authorization error on a D1 command (e.g.
7403) -> the token is missing a D1 permission."Account not found"-style error (e.g.
7404) -> the account id is wrong, not the token.Plain HTTP 403 on a REST assert (no wrapping error code) -> the token is missing that service's Read permission (Vectorize, Workers AI, ...) -- a different signature from D1's wrapped
7403.Token authenticates but nothing is found -> check whether
CLOUDFLARE_ACCOUNT_IDbelongs to the same account the token was minted for. A token from account A paired with the account id from account B passes authentication cleanly and then fails every lookup, which reads like a permissions bug and isn't one.Generic
Authentication error [code: 10000]-> three distinct causes, each needing a different fix:
| Cause | Fix |
|---|---|
| Token expired or revoked | Mint a new token, or re-enable the existing one |
| Malformed secret value (e.g. trailing whitespace from a copy-paste) | Re-set the value where CI actually reads it -- gh secret set CLOUDFLARE_API_TOKEN for GitHub Actions, or the local shell env for local deploys |
| Missing Account Settings: Read | Edit the token's permissions to add it |
wrangler secret put does not fix a bad CI token
wrangler secret put authenticates using CLOUDFLARE_API_TOKEN itself -- if that's the malformed or revoked value, the command fails before it can write anything. It also writes a runtime Worker secret (something env.SOME_KEY reads inside your code), not the CI credential -- at best that does nothing for CI, at worst it exposes the deploy token to Worker code while CI keeps using the old, broken value. Fix a malformed or rotated CI token where CI actually reads it: gh secret set CLOUDFLARE_API_TOKEN for GitHub Actions, or the shell environment for local deploys.
A working permission set for a Workers + D1 + KV + Vectorize + Workers AI stack (trim to what you actually use):
| Scope | Permission |
|---|---|
| Account -- Workers Scripts | Edit |
| Account -- D1 | Edit |
| Account -- Workers KV Storage | Edit |
| Account -- Vectorize | Read |
| Account -- Workers AI | Read |
| Account -- Account Settings | Read |
| Zone -- Workers Routes | Edit (only when using custom domains) |
| Zone -- DNS | Edit (only when using custom domains) |
For a larger, empirically-verified stack -- Workers + D1 + R2 + Queues + Vectorize + Workers AI, deploying routes with custom_domain: true -- the full working set was:
| Scope | Permission |
|---|---|
| Account -- Workers Scripts | Edit |
| Account -- D1 | Edit |
| Account -- Workers R2 Storage | Edit |
| Account -- Queues | Edit |
| Account -- Workers KV Storage | Edit (optional -- retained from the set above; trim to what you actually use) |
| Account -- Vectorize | Read |
| Account -- Workers AI | Read |
| Account -- Account Settings | Read |
| Zone -- Workers Routes | Edit |
| Zone -- Zone | Read |
| User -- User Details | Read |
Store the token and account id as GitHub Actions secrets CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID.
A custom domain needs two zone permissions, and Zone Resources must actually include the zone
custom_domain = true has Cloudflare create and manage the DNS record and the edge certificate, so Zone -- Workers Routes: Edit alone is not enough -- Zone -- DNS: Edit is doing real work here. Grant both.
Equally easy to miss: adding zone permissions does nothing while Zone Resources is left empty. The permission rows and the resource scope are separate controls, and a token with both zone rows but no zone included still fails at route creation on every deploy.
The minimal zone set isn't fixed -- it depends on wrangler version and route mode
The empirically-verified stack above deployed fine with just Workers Routes: Edit + Zone: Read -- no DNS:Edit -- which looks like a direct contradiction of the warning above. Both observations are real: the minimal zone permission set varies by wrangler version and by route mode (a dashboard-attached route behaves differently from custom_domain: true). When in doubt, grant Workers Routes: Edit + DNS: Edit + Zone: Read with Zone Resources populated, get a green deploy, then trim permissions one at a time to find the actual minimum for your setup.
A zone-permission-free alternative exists, at the cost of a manual step
If the CI token's zone permissions keep causing trouble, there's a way to avoid them entirely: keep workers_dev = false, declare no routes config in the committed file -- no "routes": [...] array in wrangler.jsonc, no [[routes]] table in wrangler.toml -- and attach the custom domain once through the dashboard (or a one-time CLI call run outside the pipeline). wrangler deploy then never touches the zone API, so the CI token needs zero zone permissions. This only holds when no routes config remains in either dialect -- add either one back and the zone API is back in play. The tradeoff: attaching the domain becomes a manual, one-time operator step that isn't version-controlled. Treat this as an option alongside a routes-config path, not a replacement for it.
Editing a token's permissions keeps the secret; creating a new token doesn't
Adding a permission to an existing token keeps the token string unchanged -- the CI secret needs no update. Creating a new token does not preserve the string -- the new value must be re-set where CI reads it: gh secret set CLOUDFLARE_API_TOKEN for GitHub Actions, or the local shell env for local deploys. It's easy to grant the missing permission on a freshly created token and then wonder why CI still fails: the pipeline is still authenticating with the old secret value.
A Freshly Attached Custom Domain Is Not Immediately Usable
wrangler deploy reports the custom domain the moment it creates the route:
Deployed my-worker triggers (1.12 sec)
my-worker.example.com (custom domain)That line means the route exists, not that the hostname works. For roughly a minute afterwards the edge can still answer with Cloudflare's own error pages (error code: 1104, or a 5xx) while the record and certificate settle. A post-deploy check that runs seconds later is racing that window, not testing your site.
Two distinct failure shapes show up, and they are easy to misdiagnose as application bugs:
AAAA lands before A. Cloudflare publishes the IPv6 record first. GitHub Actions runners have no IPv6 route, so every connection attempt in that gap fails with ENETUNREACH -- on a Worker that is perfectly healthy. Confirm by fetching the *.workers.dev URL, which is unaffected: if that returns 200, the Worker is fine and you are looking at propagation.
Node reports this via Happy Eyeballs as an AggregateError that may carry no code of its own -- the real per-address codes hang off .errors[]. Any error classifier that walks only the .cause chain misses them entirely:
function codes(error) {
const out = [];
for (let e = error; e; e = e.cause) {
if (e.code) out.push(e.code);
if (Array.isArray(e.errors)) out.push(...e.errors.flatMap(codes)); // <- required
}
return out;
}The edge answers 5xx before the origin is wired. The hostname resolves and TLS completes, but Cloudflare serves its own error page. Treat a 5xx on the first probe of a freshly attached domain as "not ready", never as a passing response.
Scope the leniency, or it becomes a blind spot
Retry-and-tolerate is correct only for the first probe, and only until it succeeds once. After the host has answered, it is demonstrably deployed -- a later failure is a real fault and must stay fatal. A tolerance that applies to every request turns a genuine outage into a silent pass.
CI Deploys from the Committed Config
CI redeploys on push to main using the committed wrangler.jsonc / wrangler.toml -- editing the file locally is not enough. If the committed config still contains REPLACE_WITH_* placeholders, CI keeps failing no matter how correct your local file is. That hard failure is the default behavior, not a platform requirement -- a template or fork repo can opt into failing gracefully instead; see Template Repo CI for the self-skip preflight pattern.
Commit the real ids -- but never secrets
What belongs in the committed config: binding ids (D1 database_id, KV namespace id), public vars (e.g. a Firebase Web API key -- it's public by design), the Worker name, the compatibility_date.
What must never be committed: real secrets, API tokens, environment-specific credentials. Those go through wrangler secret put or GitHub Actions secrets.
Known-Good Checklist
Before calling the provisioning done, verify:
The Worker was created by
wrangler deploy(no orphan dashboard Worker, no competing git-build pipeline)wrangler.jsonc/wrangler.tomlhas the final Workernameand real binding ids -- noREPLACE_WITH_*placeholders -- and is committed.assetsignoresits in the emitted assets root (viapublic/if the framework copies it into the build output)D1 migrations run with the same
CLOUDFLARE_ACCOUNT_IDthe deploy usesThe CI token includes D1:Edit if using D1, Vectorize:Read if using Vectorize (including a pre-deploy assert), and Workers AI:Read if using Workers AI (including a pre-deploy assert) -- plus KV / R2 / Queues / Routes permissions as needed -- not just the "Edit Cloudflare Workers" template
Secrets are set via
wrangler secret put/ GitHub secrets -- none of them live in the committed config
Related pages: Wrangler Config for the config format, Workers Static Assets for the assets model, and Production Deploy for the CI workflow.