One repo, many Workers
AnyRouter is one pnpm monorepo that ships as many Cloudflare Workers. A full deploy covers 17 main Workers plus two small proxy Workers. The API, the website, the dashboard, the docs, the blog and the MCP server are separate Workers. Most of them share code from the same packages.
Agents merge changes all day and deploy about once an hour. The first post in this series covers that loop. This post covers one command inside it, pnpm ops deploy, and the three questions it has to answer on every run.
- What is each Worker running right now?
- Which Workers can this change affect?
- Is this checkout newer than production?
Why we stopped deploying everything
Until early October, a deploy shipped the whole fleet whenever a shared package changed, and almost every change touches a shared package. The median full deploy took about 1,130 seconds, measured over the last 5 successful full runs in our deploy log from 6 to 9 October.
The biggest phase took 385 seconds. It crawls the model catalog into static HTML and then redeploys the web Worker with those pages. Only the web Worker ships that HTML. A change to the docs alone still paid for the crawl, and took about 513 seconds, of which 425 seconds produced pages that were never deployed.
Each Worker records its own commit
The first fix was to stop guessing what production runs. Every wrangler deploy that ops runs passes --message anyr-sha=<commit>. Cloudflare stores that message on the Worker version, so any machine with the API token can read it later. It costs nothing and needs no new database.
A plain pnpm ops deploy reads the live version of every Worker in parallel. That read took 1.8 seconds when we measured it on 12 October. A secret update creates a new version with no message, so the reader skips those versions and finds the code version under them.
Then the diff for each Worker is <its live commit>..HEAD, plus any uncommitted changes. We used to diff against origin/main. That diff is empty as soon as a merge is pushed, and on 11 October a --since HEAD~1 deploy skipped a merge that sat under a later docs commit. The live commit is the only base that matches what is actually running.
A Worker whose commit can't be read always deploys. That covers a Worker that was never stamped, a deploy from a dirty tree, or an API error. Deploying too much is safe. Deploying too little is not.
The import graph picks the targets
Next, scripts/lib/worker-graph.ts maps changed files to Workers. For each Worker it collects the workspace packages it depends on and the @anyrouter/* packages its source actually imports, then follows the same rule through each of those packages. Test files never add an edge.
The shared UI package is handled per file. For each Worker, the planner walks the imports from that Worker's own source into packages/ui. A changed UI file deploys only the Workers that reach it, and the dry run names the file that imports it. If the walk hits an import it can't follow, such as a dynamic import() with a template string, that Worker counts as reaching all of the UI package.
Some inputs can change every bundle, so they force the full path. These include package.json, the lockfile, root TypeScript and Vite config, the deploy scripts themselves, database migrations, and any path the planner does not recognize. A kb note under docs/kb deploys nothing on its own.
pnpm ops deploy --dry-run prints the plan with one row per Worker, the reason for each target, and a time estimate. These are the planner's estimates for a few kinds of change, built from the phase medians in our deploy log:
| Change | Deploys | Estimate | Before |
|---|---|---|---|
| Dashboard only | dashboard | about 65 s | about 1,130 s |
| Docs app only | docs | about 95 s | about 513 s |
| Home page sections | web and its runtime dependency | about 620 s | about 1,130 s |
| A kb note only | nothing | 0 s | 0 s |
A stale checkout cannot roll prod back
On 12 October two machines ran pnpm ops deploy at the same time. One deployed a newer commit over the other in the middle of its run, so the first run failed its final check. No harm was done that time, because the newer build contained the older commits. In the other order, production would have gone back to an older build.
Two guards now run before the first upload or migration.
- A deploy lease. The deploy writes a lease key to Workers KV with its machine, commit, start time and a random nonce, and a 25-minute expiry. KV has no compare-and-set, so the writer waits 1 second and reads the key back. Only the machine whose nonce is stored owns the lease. Another machine waits, polling every 15 seconds, and gives up after 15 minutes by default. A background process extends the lease every minute while builds run.
- A rollback guard. After taking the lease, the deploy compares HEAD with each target's live
anyr-sha. If HEAD is a strict ancestor of a live commit, it aborts with a message that says production is newer and tells you to pull. A live commit that git can't resolve never blocks the deploy.
A third check came from a different incident. A deploy reads the shared checkout for minutes, and once another session ran git pull there in the middle of a build. That deploy crawled pages with a newer script against an older bundle, and 4 model pages fell back to server rendering. Now ops deploy records HEAD and the tracked file state when it starts, and checks both again before every build, upload and crawl. If either changed, it aborts and names the Workers it already shipped.
The crawl is cached and faster
Every web deploy replaces the Worker's static files, so it must ship the prerendered pages every time. Dropping them would send the catalog back to server rendering. So the planner never skips the crawl. It reuses one instead.
The cache key is a hash of the built bundle, not of the source, because the HTML embeds the build's asset names. A retry of a failed deploy on the same build reuses the crawl and skips it entirely. A deploy that does not touch the web Worker does not crawl at all.
The crawl also got faster on its own. It ran one page at a time with an 800 ms pause, settings chosen for the Workers Free plan. On the paid plan it now runs 2 pages at a time with a 100 ms pause. In the 18 successful deploys logged on one machine on 25 and 26 October, the same step, crawl plus web redeploy, took 84 to 129 seconds, with a median of about 100. It used to take 385.
What it costs today
Here is one machine's deploy log for 25 and 26 October. Other machines deploy too, so this is a sample, not a total.
- 18 successful deploys and 3 failed ones in the changed-files mode.
- A median of 522 seconds from start to finish across all 18, and a median of 419 seconds until production served the new code and passed a 5-second critical smoke test. The full crawl and checks run after that point.
- 11 of the 18 resolved to the full path, with a median of 530 seconds. The other 7 shipped between 11 and 17 Workers, with a median of 493 seconds.
That last number matters most. The agents themselves change the deploy scripts, the lockfile and shared packages often, and each of those forces a wide deploy. Per-Worker targeting saves the most on small, local changes, such as a dashboard fix or a docs edit. So the drop for full-path runs, from about 1,130 to 530 seconds, comes mostly from two other changes. The crawl went from 385 to about 100 seconds, and uploads now run while the next build runs. That is our reading of the phase timings in the log, not a controlled test.
Next, we want fewer inputs that force the full path. The dashboard is one example. When we counted on 10 October, 102 of the 126 dashboard UI files the dashboard Worker reaches were also bundled into the web Worker through an old route tree, so editing one of them deployed the web Worker too. Deleting that old tree would make those edits deploy the dashboard alone.
Related posts
Building an LLM gateway on Cloudflare Workers
AnyRouter runs as a set of Cloudflare Workers, not a long-running server. That constrains the design in specific ways — a 128MB memory ceiling that rules out buffering responses, a 3MiB script cap that forced a multi-Worker split, and a hard rule that every upstream call goes through Cloudflare's AI Gateway. Here's what that architecture actually looks like.
How automatic failover works in an LLM gateway
A single provider call has a single point of failure: one rate limit, one outage, one bad deploy on their end, and your request fails. Here's the actual mechanism AnyRouter uses to route around that — circuit breakers, escalating cooldowns, and a fallback chain — and why the extra hop barely registers next to model generation time.
Route your first request in 2 minutes
Start free with your own keys, or top up and pay per token. Get $4/mo in credits and free models on Go — $2/mo, or free when you donate a provider key.
Start free