Runbook 03 · Cloudflare Workers

Workers static assets: the deploy succeeded, the site did not

Four different deploy shapes end in the same place: wrangler deploy exits 0 and the public hostname is not the build you shipped. The source index.html comes back with every hashed asset 404ing; or yesterday's HTML returns with CF-Cache-Status: HIT; or a staging deploy quietly takes the custom domain off production. Platform: Cloudflare Workers — Workers static assets + @cloudflare/vite-plugin. Change: wrangler 4.99.0–4.110.x and @cloudflare/vite-plugin 1.15.x — the window reviewed here. The SPA-fallback regression is fixed in 4.101.0; the asset-binding failure is still open. Detection chapter reviewed: 2026-11-08. The paid chapter is still in verification on a live setup; the status notice in the appendix stays until that finishes.

What changed

There is no single "static assets changed on date X" event behind this runbook. There are four separate things, in four overlapping version windows, that all end with a green deploy over a wrong site. Keeping them apart is the whole point: each has a different fix, and only two of them are version bugs.

1. What gets uploaded is decided by the build, not by your wrangler config

  • The Vite plugin generates the deploy config. On vite build, the plugin writes an output wrangler.json whose assets.directory points at the client build output, so you do not need to set assets.directory in your own config (Cloudflare Vite plugin docs, reviewed 2026-11-08).
  • Deploy commands follow a redirect file. wrangler deploy, wrangler dev and wrangler versions upload look up the tree for .wrangler/deploy/config.json and, when it exists, load the configuration that file names instead of the project's own file. Wrangler has supported the redirect since workers-sdk PR #7442 (merged 2025-01-08); the Vite plugin writes the redirect during the build, as described in the deploy flow of issue #15860.
  • The failure this enables. If the build did not run, ran in a different directory, or the checkout does not contain the generated output, Wrangler falls back to the project configuration and uploads whatever assets.directory points at — often the source directory that still holds the unbuilt index.html. That is the symptom reported in issue #15860 (inferred from the mechanism; the symptom itself is reviewed).
  • The upload is a manifest of path → hash → size. Files whose hashes were already uploaded are not sent again; that deduplication is expected and is not itself an error (Workers static assets direct-upload docs, reviewed 2026-11-08).

2. The SPA fallback regressed in 4.99.0 and was fixed in 4.101.0

  • 4.98.0, the version probed as good. With not_found_handling = "single-page-application", an inherited top-level routes block and a named environment, wrangler dev --env <name> returned 200 with index.html for GET /.
  • 4.99.0, the regression. The same command returned the Worker's raw 404 for GET /, while /projects and /api/* were unchanged. Both triggers were required: --env and an inherited routes block. Dropping either one restored the old behaviour. Locking back to 4.98.0 was the workaround (workers-sdk issue #14255, opened 2026-06-11).
  • Fixed 2026-06-15. PR #14282 ("preserve asset fallback with routes") disabled automatic route matching for the dev server; it first shipped in wrangler 4.101.0. The same regression also affected not_found_handling = "404-page" (comment in #14255).
  • This is a dev-server bug, and the deploy-time confusion around it is separate. With the Vite plugin the Cloudflare environment is selected at build time through CLOUDFLARE_ENV; passing it to vite preview or wrangler deploy has no effect (Cloudflare Vite plugin environments docs, reviewed 2026-11-08). "I added --env and it still deployed the other environment" is not #14255.

3. In 4.110.0 a deploy can report success before its assets are actually served

  • What was reported. workers-sdk issue #15860, opened 2026-09-25: wrangler 4.110.0, @cloudflare/vite-plugin 1.15.3, Node v26.7.0, Windows 11, the plugin's redirected-config flow. Three times in about a day the Worker served the raw source index.html (referencing /src/react-app/main.tsx) and every hashed JS/CSS asset 404'd — on the workers.dev hostname and both custom domains at once, despite a successful deploy with no warning.
  • What preceded it. Two of the three broken deploys were directly preceded by a deploy logging No updated asset files to upload. Proceeding with deployment...; the third uploaded genuinely new assets and still served the broken state, so "skip re-upload" does not explain all of it.
  • The cache header. Every broken response carried CF-Cache-Status: HIT. The reporter's own reading is that this does not look like ordinary edge-cache staleness — the body served was never a deployed asset — and suspects the deployment's asset manifest or binding step. Cloudflare has not confirmed a cause (inference, not a documented behaviour).
  • State at review. Open, labelled needs-reproduction, package:wrangler and feature:workers-assets, last updated 2026-10-23. A 2026-10-17 comment reports a related shape — zero-byte 404s for every asset on an inactive assets-only version — on wrangler 4.151.0, without ETag, Age or CF-Cache-Status headers.

4. An inherited routes block moves a custom domain on every deploy

  • routes is an inheritable key. An env.<name> block that does not override routes inherits the top-level array. When that array contains custom_domain: true, every deploy to the named environment re-asserts the domain on the environment's Worker, and only one Worker can own a custom-domain record.
  • The observed effect. workers-sdk issue #13925 (wrangler 4.81.0, opened 2026-05-15, closed 2026-06-03 as a docs/UX issue): wrangler deploy --env staging moved the apex domain from the production Worker to the staging Worker, every time, in both directions. Production traffic then served the staging build.
  • The only signal is a normal-looking log line. Each deploy prints Deployed <worker> triggers ... <hostname> (custom domain). Nothing in the output says ownership just changed.
  • 4.98.0 added a warning, not a guard. PR #13979 (merged 2026-06-03) makes Wrangler warn: The "env.staging" environment inherits the top-level `routes` configuration, which includes the custom domain(s): <hostname>. Deploying this environment will reassign these custom domains away from the top-level Worker. Add "routes": [] to "env.staging" to prevent inheritance. The deploy still proceeds; "routes": [] in the env block is still the fix.

Version window reviewed:

ComponentVersionWhat it means here
wrangler4.98.0SPA fallback still correct; custom-domain inheritance warning added (PR #13979).
wrangler4.99.0 – 4.100.xRoot-path SPA fallback returns the Worker 404 in dev when --env and inherited routes are both present (issue #14255). 4.100.x is inside this window by release order: 4.100.0 predates the 2026-06-15 fix merge, and the issue probed only 4.98.0 against 4.99.0.
wrangler4.101.0Contains PR #14282, the SPA-fallback fix, per the 4.101.0 release notes.
wrangler4.110.0The version named in the open asset-binding report (issue #15860).
@cloudflare/vite-plugin1.15.3The plugin version in the #15860 report; the redirected-config deploy flow.
wrangler (latest at review)4.158.0npm registry on 2026-11-08; #15860 is not closed against it.

Reviewed against the versions named in the issues and release notes above. Nothing in this runbook was re-run on a live account at review time — the detection chapter is a reading of the published behaviour, and the appendix marks each claim's source.

Detect: are you affected?

Three read-only checks: your versions, what the public hostname actually serves, and whether that response is the build on your disk. Run them in that order. If all three are clean, stop — this is not the runbook and you should not buy it.

Check 1 — versions, and which window you are in

npx wrangler --version
npm ls wrangler @cloudflare/vite-plugin --depth=0 2>/dev/null | grep -E "wrangler|vite-plugin"
OutputMeaningNext step
wrangler 4.98.xThe fallback still works; the inherited-domain warning is present.Continue to check 2.
wrangler 4.99.x / 4.100.xThe root-path SPA fallback regression is present in dev when --env and inherited routes are both in play.Continue to check 2, then read failure mode 05.
wrangler 4.101.x – 4.109.xThe SPA fix is present; the asset-binding issue is not reported in this range in the threads reviewed.Continue to check 2.
wrangler 4.110.x / 4.15xThe environment of the open report (#15860). Treat every green deploy as unverified until check 2 and 3 agree.Continue to check 2.
@cloudflare/vite-plugin 1.15.xThe exact plugin line in the #15860 report, using the redirected-config deploy flow.Continue to check 2.
no local installA globally installed or npx-resolved wrangler may not be the one you are reading about.Pin wrangler in package.json first, then re-run this check.

Check 2 — what the public hostname actually serves

Ask for the page and for the first hashed asset it references. Replace $HOST with the hostname your users hit, including any custom domain.

curl -sS -D - -o /dev/null "https://$HOST/"
curl -sS "https://$HOST/" | grep -oE '(/src/[^"]*|/assets/[^"]*\.(js|css))' | head -n 5
ASSET=$(curl -sS "https://$HOST/" | grep -oE '/assets/[^"]+\.(js|css)' | head -n 1)
echo "$ASSET"; curl -sS -o /dev/null -w "%{http_code}\n" "https://$HOST$ASSET"
OutputMeaningNext step
HTML contains /src/... or main.tsxThe unbuilt source index.html is being served. This is the #15860 shape, not a cache problem.Failure mode 01.
hased asset → 404The HTML calls an asset that the deployment does not serve: HTML and assets are from different builds, or the asset set never bound.Failure mode 03, and failure mode 01 if the HTML is source.
cf-cache-status: HIT + age: <n>A stored copy is being served. Age is seconds since the copy was admitted or revalidated.Failure mode 04; confirm with check 3 before concluding.
GET / → 404 with your Worker's bodyThe SPA fallback is not rewriting / to index.html.Failure mode 05.
200, hashed asset 200, no HITThe site serves a build and its assets. This runbook's deploy failures are not what you are seeing here.Go to check 3 to rule out a domain handover, then stop.

Check 3 — is the served page the build on this disk?

cat .wrangler/deploy/config.json 2>/dev/null || echo "no redirected config"
curl -sS "https://$HOST/" | sha256sum
sha256sum dist/client/index.html
npx wrangler deployments status --name "$WORKER_NAME"
OutputMeaningNext step
no redirected configThe Vite build did not run in this directory, or the project does not use the plugin. Deploy may have used your own config and the wrong assets.directory.Failure mode 06.
configPath → dist/.../wrangler.jsonThe build ran and the deploy reads the generated config. Read its assets block.Compare the two hashes below.
hashes equalThe served HTML is your build. The problem is a domain, a stale copy or a second Worker, not the build.Failure mode 02 or 04.
hashes differAn older build, an older copy, or a different Worker is answering. If Age is large, suspect the stored copy; if not, suspect the deployment.Failure mode 04, then failure mode 01.
deployments status: auth or name errorYou cannot read the deployment state from this machine.Check the same information in the dashboard before changing anything.

Exit condition: wrangler outside the 4.99.0–4.110.x window (or on a version whose release notes you have read), check 2 serving your build with no stored copy, and check 3 showing equal hashes. That is a different problem — do not buy this runbook for it.

Failure modes

Six failures are covered by this runbook, each with the error shape, the trigger, the fix and a verification command. Two are visible here: the two that get misdiagnosed as "wrangler is flaky" and "Cloudflare moved my domain".

01 — Green deploy, unbuilt index.html, hashed assets 404, CF-Cache-Status: HIT
wrangler deploy exits 0. The hostname serves the source index.html — something like <script type="module" src="/src/react-app/main.tsx"> — every hashed JS/CSS file from the last good build returns 404, and responses carry CF-Cache-Status: HIT. This is the shape in workers-sdk issue #15860 (wrangler 4.110.0, vite-plugin 1.15.3; open, labelled needs-reproduction). The reporter's own observation is that this does not look like ordinary staleness — the body never was a deployed asset — and suspects the asset manifest or binding step; Cloudflare has not confirmed a cause. What you can do tonight: redeploy (it cleared all four occurrences), then verify the response hash instead of trusting exit 0. Treat the first green deploy after an asset change as unverified until curl agrees with your build.
02 — The staging deploy that took the custom domain
env.staging has no routes key, the top-level config has routes: [{ pattern: "<apex>", custom_domain: true }], and wrangler deploy --env staging reassigns the custom-domain record from the production Worker to the staging Worker. Deploy output prints a normal-looking Deployed <worker> triggers ... <hostname> (custom domain) line and production traffic is on the other build. Since wrangler 4.98.0 you also get a warning naming the domain and telling you to add "routes": [] — and the deploy still proceeds. The fix is "routes": [] in every env block that must not own the domain, plus an ownership check after each deploy.
The remaining four, and the migration, are in the paid bundle
Failure modes 03–06: hashed assets that 404 because HTML and assets come from different builds; CF-Cache-Status: HIT returning yesterday's HTML after a deploy, and how to tell it apart from the manifest failure; not_found_handling = "single-page-application" that stops rewriting / with --env plus inherited routes (and why CLOUDFLARE_ENV at deploy time does nothing with the Vite plugin); and the build-output directory that is wrong or gitignored, including the verbatim The directory specified by the "assets.directory" field in your configuration file does not exist: error. Plus the migration checklist split by caller and component, the rollback plan, and the 24-hour watch list. It arrives as Markdown files, one per chapter, so your agent can read them directly. Access runs for 30 days from purchase, and a refund revokes it.

Migration: what the paid chapter covers

The fix depends on which part of the pipeline you own, so the paid chapter is split that way. A change that repairs the deploy job can still leave a browser on a stored copy, and a config change that fixes staging can move a domain off production.

Caller / componentWhat breaksWhat the runbook has you change
Deploy job (CI)Exit 0 is treated as success, so a broken deploy is promotedPost-deploy verification: fetch /, compare the sha256 with dist/client/index.html, fetch one hashed asset and require 200; fail the job on mismatch
Vite build stepOutput is not where Wrangler reads it, or the environment is chosen wrongBuild and deploy in the same job, directory and CLOUDFLARE_ENV; confirm the redirected config before deploying
Wrangler config / envsInherited routes and assets make an env do something it does not declareExplicit "routes": [] in non-production envs; read assets and not_found_handling per environment
Human browserGets the source page, or yesterday's HTML from a stored copyVerify the body hash, then treat HTML as short-lived and hashed assets as immutable; purge or version deliberately
Agent / crawlerParses whatever HTML the edge returns, including unbuilt sourceSame hash check; alert on the 404 rate for /assets/* after every deploy
Incident responderRolls back the code but not the domain, or the other way roundwrangler rollback to the previous version; re-check domain ownership separately, because a rollback does not move a custom domain back

Includes rollback: which changes are safe to revert alone, which must be reverted together (the deploy and the domain ownership), and what stays broken after a rollback.

Appendix

Verification status
The detection chapter above is reviewed against the sources linked in this appendix and is complete. The paid chapter (the remaining failure modes and the migration checklist) is still in re-verification on a live setup, so treat it as provisional until that finishes. Price and structure are final. A 7-day refund applies from the day of purchase — see the refund policy.
For agents

Need a runbook that does not exist yet?

Runbooks are written by an AI from requests. Submit the platform change, the versions affected and public evidence; accepted requests become the next runbook.

Request one
Agents: POST /api/requests with JSON, or read the instructions at /requests.md. Accepted and published requests are listed at /api/requests. Humans: email [email protected].

Green deploy, wrong site?

Name the wrangler and vite-plugin versions, the hostname, and what the HTML references. If it is one of these deploy failures, the checklist starts from a response you already have.