Deploys quirúrgicos en un monorepo con Cloudflare: cómo evitar builds innecesarios sin romper nada
Diez días sirviendo código viejo sin darnos cuenta. La arquitectura real de Workers Builds en un monorepo, y los Build Watch Paths que nadie menciona.
Descubrimos hace dos semanas que llevábamos diez días commiteando cambios a producción sin que ninguno llegara al usuario.
No era un bug. No era un deploy fallido. Era algo peor: el script de deploy ejecutaba con éxito, el CI pasaba, la terminal decía "deployed", y agentikas.ai seguía sirviendo código del repo anterior, congelado antes de la migración al monorepo. El sitemap listaba URLs de un piloto de restaurantes que ya no existía. Google no indexaba el blog nuevo. Nadie se dio cuenta hasta que alguien preguntó por qué no aparecía en búsquedas.
La raíz era trivial — un nombre de Worker desincronizado entre el wrangler.jsonc y el que realmente tenía las rutas atadas en Cloudflare. Pero el hecho de que tardáramos diez días en notarlo apunta a un problema más estructural: la relación entre un monorepo y Cloudflare Workers es más sutil de lo que parece, y los defaults te llevan a configuraciones que son correctas pero ineficientes, o rápidas pero frágiles.
Esta es la arquitectura de deploy que acabamos montando, y lo que aprendimos por el camino.
La arquitectura real: un dominio, varios Workers
El punto de partida es entender que Cloudflare no despliega "el monorepo". Cloudflare despliega Workers individuales, cada uno con su propio nombre, su propia versión, su propia configuración, y sus propias rutas.
En nuestro caso, agentikas.ai está servido por al menos tres Workers distintos:
agentikas-landing → agentikas.ai/{sitemap.xml, robots.txt, /es/*, /en/*}
agentikas-blog → *.blog.agentikas.ai/*
agentikas-dashboard → (no expuesto, herramientas internas)
Más otros tantos para subdominios específicos (mcp., voice., widget., api-linkedin., etc.) que están conectados a otros Workers cada uno.
Cada uno de estos Workers vive en una carpeta distinta del monorepo: apps/landing, apps/web, apps/dashboard. Y cada uno tiene su propio wrangler.jsonc, su propia build, sus propias secrets.
El error que cometimos: pensar que un monorepo implica un deploy
Cuando migramos del repo standalone al monorepo, renombramos el wrangler.jsonc de apps/landing de agentikas-seo (el nombre histórico) a agentikas-landing (más descriptivo). Ese cambio pasó review sin problema — nadie objetó un rename de string.
Lo que no hicimos fue reconfigurar Cloudflare. El Worker agentikas-seo seguía teniendo todas las rutas de agentikas.ai/* en el dashboard. Y el Worker agentikas-landing no existía todavía — no hasta el primer wrangler deploy con el nombre nuevo.
Durante diez días, el script npm run deploy creaba y actualizaba un Worker huérfano (agentikas-landing) que no recibía tráfico. Todo el tráfico real seguía yendo al Worker viejo (agentikas-seo), que no había recibido un deploy desde antes de la migración.
La lección no es "revisa siempre el dashboard después de migrar" (aunque sí). La lección es que en Cloudflare, el nombre del Worker y el código que lo compone son recursos independientes del routing que dirige tráfico hacia él. Hay que pensarlos por separado.
Git integration: una por Worker, no una por repo
Cloudflare ofrece una funcionalidad llamada Git integration (o "Workers Builds") que conecta un Worker a una rama de GitHub: cada push a main dispara un build automático y un deploy. La descripción suena como si fuera a nivel de repo — no lo es. Es a nivel de Worker.
Si tienes 3 Workers en tu monorepo, configuras 3 Git integrations. Cada una apunta al mismo repo pero con parámetros distintos:
- Root directory: la carpeta donde vive el Worker (
apps/landing,apps/web, etc.) - Build command: cómo compilas ese Worker (
npm run build:cf) - Deploy command: cómo lo subes (
npx wrangler deploy) - Build Watch Paths: los directorios cuyos cambios deben disparar un rebuild
El Root directory es la configuración más importante y la que más se olvida. Sin él, Cloudflare intenta correr npm install y wrangler deploy desde la raíz del repo, donde no hay configuración de despliegue — la build falla con un error que parece un bug de Wrangler pero es un problema de working directory.
Build Watch Paths: el setting que casi nadie conoce
Por defecto, cada Git integration builddea en todos los pushes a main, aunque el commit no toque nada del Worker. En un monorepo, eso significa que un cambio en apps/dashboard dispara un rebuild innecesario de apps/landing, apps/web, y cualquier otro Worker conectado al mismo repo.
El setting que arregla esto se llama Build Watch Paths: una lista de globs que, si el commit toca al menos un archivo que matchee, disparan el build. Si ninguno matchea, el build se salta.
Parece un setting menor. No lo es. La factura de Cloudflare Workers Builds escala con build minutes consumed, y los builds de OpenNext no son baratos — entre 30 segundos y varios minutos según el tamaño del bundle. Para un monorepo con actividad media (10-20 commits al día), la diferencia entre builds triggereados a granel vs. quirúrgicamente puede ser 5-10× en coste y en tiempo de CI bloqueado.
El problema de los packages compartidos
Aquí es donde la mayoría de tutoriales se quedan cortos. Si tus Build Watch Paths son apps/landing/** y punto, estás cubriendo solo el caso feliz. Los workspaces en un monorepo comparten paquetes. En nuestro caso:
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/)
Si packages/blog-ui/ cambia y solo vigilas apps/landing/**, el build no se dispara. El Worker sigue con la versión antigua del paquete porque en Cloudflare no hay step de "reinstalar dependencias" que actualice el bundle — el build se salta entero.
Esto genera un tipo muy sutil de regresión: el Worker local usa la versión nueva del package (porque npm install resuelve el workspace como symlink), pero el Worker de producción tiene la versión antigua bundled dentro. Tests pasan localmente, falla silenciosamente en prod.
La regla que aplicamos:
Los Watch Paths de cada Worker deben incluir su propio directorio + cada uno de los packages que importa + el
package.jsonypackage-lock.jsondel root.
Para agentikas-landing, que solo importa de @agentikas/blog-ui, la configuración quirúrgica es:
apps/landing/**
packages/blog-ui/**
package.json
package-lock.json
Los dos últimos cubren el caso de bump de versión de una dependencia externa o regeneración del lockfile — sin ellos, un npm update no llegaría a producción hasta el próximo cambio en apps/landing.
Quirúrgico vs. conservador
Hay una tentación, cuando se entiende este patrón, de hacerlo extremadamente preciso: watch paths específicos por cada subdirectorio, por cada archivo, por cada tipo. Es un error.
La precisión tiene un coste: cada vez que añades un nuevo package compartido o cambias el grafo de dependencias, tienes que actualizar los Watch Paths en 3 dashboards distintos. Se olvida. Y cuando se olvida, vuelves al problema de antes — código que no llega a prod, o peor, código que llega a medias.
El balance que nosotros aplicamos: cada Worker vigila su propio directorio + los packages que importa hoy + root package.json/lock. Si mañana añadimos un nuevo package compartido, actualizamos los Watch Paths como parte del mismo commit. No intentamos predecir qué podría cambiar.
Configuración actual de agentikas.ai
Para cerrar con la tabla concreta — esto es lo que tenemos hoy en producción:
Worker Root directory Build Watch Pathsagentikas-landing
apps/landing
apps/landing/**, packages/blog-ui/**, package.json, package-lock.json
agentikas-blog
apps/web
apps/web/**, packages/blog-ui/**, package.json, package-lock.json
agentikas-dashboard
apps/dashboard
apps/dashboard/**, packages/ai/**, packages/blog-ui/**, package.json, package-lock.json
Cada uno con Build command: npm install && npm run build:cf y Deploy command: npx wrangler deploy.
Un commit que solo toca skills/ o supabase/migrations/ no triggerea ningún build. Un commit que toca packages/blog-ui/ triggerea los dos primeros (no dashboard). Un commit a apps/web/ triggerea solo el de blog. Es quirúrgico por definición, y si alguien añade una dependencia cruzada nueva, se actualiza en el mismo PR.
Verificación rápida
Después de configurar Git integration por primera vez, el test es trivial: haz un commit que solo toque un archivo dentro del Root directory del Worker, pushea a main, y mira el dashboard. Deberías ver un deploy nuevo con el hash del commit como tag. Si no lo ves, el Watch Path está mal. Si ves tres deploys cuando solo esperabas uno, tienes overlap en los Watch Paths entre Workers.
Un segundo test más duro: haz un commit que toque solo un package compartido (packages/blog-ui/...). Deberían triggerearse los Workers que lo consumen, y solo esos. Ese es el test que detecta el bug de "mi paquete cambió pero producción sigue con la versión vieja".
Agentikas es open source. Si quieres ver nuestra configuración real — los wrangler.jsonc, los scripts de build, el monorepo entero — está en github.com/agentikas/agentikas-blog. El commit que resuelve la historia del principio es el PR #37 ("fix(landing): migrate production from agentikas-seo to agentikas-landing"), y los Build Watch Paths se configuraron poco después.
Comentarios
Cargando comentarios…
Inicia sesión en tu dashboard para participar.