Skip to main content
← Back to blog

Surgical deploys in a Cloudflare monorepo: how to skip useless builds without breaking anything

Ten days serving stale code without noticing. The real architecture of Workers Builds in a monorepo, and the Build Watch Paths nobody mentions.

We discovered two weeks ago that we had been shipping commits to production for ten days without a single one reaching users.

It wasn't a bug. It wasn't a failed deploy. It was worse: the deploy script ran successfully, CI was green, the terminal said "deployed," and agentikas.ai kept serving code from the old repo — frozen since before the monorepo migration. The sitemap listed URLs for a restaurants pilot that no longer existed. Google didn't index the new blog. Nobody noticed until someone asked why the site wasn't ranking.

The root cause was trivial — a Worker name out of sync between wrangler.jsonc and the one actually bound to the routes in Cloudflare. But the fact that it took us ten days points to something more structural: the relationship between a monorepo and Cloudflare Workers is subtler than it looks, and the defaults lead you into configurations that are either correct-but-wasteful or fast-but-fragile.

This is the deploy architecture we ended up with, and what we learned along the way.

The real architecture: one domain, many Workers

Cloudflare does not deploy "the monorepo." It deploys individual Workers, each with its own name, its own version, its own config, and its own routes.

In our case, agentikas.ai is served by at least three different Workers:

agentikas-landing  →  agentikas.ai/{sitemap.xml, robots.txt, /es/*, /en/*}
agentikas-blog     →  *.blog.agentikas.ai/*
agentikas-dashboard → (not exposed, internal tooling)

Each of these Workers lives in a different folder of the monorepo: apps/landing, apps/web, apps/dashboard. Each has its own wrangler.jsonc, its own build, its own secrets.

The mistake: thinking a monorepo implies one deploy

When we migrated from the standalone repo to the monorepo, we renamed the wrangler.jsonc in apps/landing from agentikas-seo (the legacy name) to agentikas-landing (more descriptive). That change passed review — no one objects to a string rename.

What we didn't do was reconfigure Cloudflare. The agentikas-seo Worker still had all agentikas.ai/* routes in the dashboard. The agentikas-landing Worker didn't exist yet — not until the first wrangler deploy with the new name.

For ten days, npm run deploy was creating and updating an orphan Worker (agentikas-landing) that received zero traffic. All real traffic kept flowing to the old Worker (agentikas-seo), which hadn't been deployed since before the migration.

The lesson isn't "always check the dashboard after a migration" (though, yes). The lesson is that in Cloudflare, the Worker's name and the code it contains are resources independent from the routing that sends traffic to it. You have to think about them separately.

Git integration: one per Worker, not one per repo

Cloudflare offers a feature called Git integration (or "Workers Builds") that connects a Worker to a GitHub branch: every push to main triggers a build and a deploy. The description sounds like it's at the repo level — it isn't. It's at the Worker level.

If you have 3 Workers in your monorepo, you set up 3 Git integrations. Each points at the same repo but with different parameters:

  • Root directory: the folder where the Worker lives (apps/landing, apps/web, etc.)
  • Build command: how you compile that Worker (npm run build:cf)
  • Deploy command: how you push it (npx wrangler deploy)
  • Build Watch Paths: the directories whose changes trigger a rebuild

Root directory is the most important setting and the one most people forget. Without it, Cloudflare tries to run npm install and wrangler deploy from the repo root, where there's no deploy config — the build fails with an error that looks like a Wrangler bug but is actually a working-directory issue.

Build Watch Paths: the setting almost nobody knows

By default, each Git integration rebuilds on every push to main, even if the commit touches nothing belonging to that Worker. In a monorepo, that means a change in apps/dashboard triggers a pointless rebuild of apps/landing, apps/web, and any other Worker wired to the same repo.

The fix is Build Watch Paths: a list of globs that, if the commit touches at least one matching file, trigger the build. If nothing matches, the build is skipped.

Looks like a minor setting. It isn't. The Cloudflare Workers Builds bill scales with build minutes consumed, and OpenNext builds aren't cheap — 30 seconds to several minutes depending on bundle size. For a monorepo with moderate activity (10–20 commits a day), the difference between triggering builds in bulk vs. surgically can be 5–10× in cost and blocked CI time.

The shared-package problem

This is where most tutorials fall short. If your Build Watch Paths are just apps/landing/**, you're only covering the happy path. Workspaces share packages. In our case:

apps/landing   imports from @agentikas/blog-ui  (packages/blog-ui/)
apps/web       imports from @agentikas/blog-ui  (packages/blog-ui/)
apps/dashboard imports from @agentikas/ai       (packages/ai/)

If packages/blog-ui/ changes and you only watch apps/landing/**, the build won't fire. The Worker stays on the old version because Cloudflare has no "reinstall dependencies" step that would refresh the bundle — the whole build is skipped.

This creates a particularly sneaky regression: the local Worker uses the new version of the package (npm resolves the workspace as a symlink), but the prod Worker has the old version bundled in. Tests pass locally, prod silently fails.

The rule we apply:

Each Worker's Watch Paths must include its own directory + every shared package it imports + the root package.json and package-lock.json.

For agentikas-landing, which only imports @agentikas/blog-ui, the surgical config is:

apps/landing/**
packages/blog-ui/**
package.json
package-lock.json

The last two cover external dependency bumps and lockfile regenerations — without them, an npm update wouldn't reach production until the next change in apps/landing.

Surgical vs. conservative

There's a temptation, once you grasp this pattern, to make it extremely precise: specific watch paths per subdirectory, per file, per type. Mistake.

Precision has a cost: every time you add a new shared package or change the dependency graph, you have to update Watch Paths in 3 dashboards. It gets forgotten. And when it does, you're back to the original problem — code that doesn't reach prod, or worse, code that partially reaches it.

The balance we settled on: each Worker watches its own directory + the packages it imports today + root package.json/lock. If a new shared package lands tomorrow, we update Watch Paths in the same commit. We don't try to predict what might change.

Current agentikas.ai configuration

For closing with a concrete table — this is what we have in production today:

WorkerRoot directoryBuild Watch Paths agentikas-landingapps/landingapps/landing/**, packages/blog-ui/**, package.json, package-lock.json agentikas-blogapps/webapps/web/**, packages/blog-ui/**, package.json, package-lock.json agentikas-dashboardapps/dashboardapps/dashboard/**, packages/ai/**, packages/blog-ui/**, package.json, package-lock.json

All with Build command: npm install && npm run build:cf and Deploy command: npx wrangler deploy.

A commit that only touches skills/ or supabase/migrations/ triggers no builds. A commit touching packages/blog-ui/ triggers the first two (not dashboard). A commit to apps/web/ triggers only the blog one. It's surgical by definition, and if someone adds a new cross-dependency, it's updated in the same PR.

Quick verification

After setting up Git integration for the first time, the test is trivial: make a commit touching only one file inside the Worker's Root directory, push to main, check the dashboard. You should see a new deploy with that commit hash. If you don't, the Watch Path is wrong. If you see three deploys when you expected one, your Watch Paths overlap across Workers.

A harder second test: make a commit touching only a shared package (packages/blog-ui/...). The Workers consuming it should trigger — and only those. That's the test that catches the "my package changed but prod still has the old version" bug.


Agentikas is open source. If you want to see our real config — the wrangler.jsonc, the build scripts, the full monorepo — it's in github.com/agentikas/agentikas-blog. The commit that resolves the story at the start is PR #37 ("fix(landing): migrate production from agentikas-seo to agentikas-landing"), and the Build Watch Paths were configured shortly after.

Comments

Loading comments…