18 días sin desplegar: la autopsia del build de mi blog
Mi blog estuvo 18 días sin publicar nada. Y no me enteré.
Tengo excusa, y es la razón por la que esto estuvo roto tanto tiempo: he estado de baja por una lesión ocular que no me permitía ver el monitor correctamente. Así que el blog siguió funcionando solo, como estaba diseñado, y yo no estaba delante para ver que no.
Lo peor no es el fallo. Lo peor es que todo salía en verde: los posts se escribían solos, se commiteaban a master, los workflows terminaban con su tick, y las recopilaciones semanales de los domingos aparecían puntualmente en el repositorio. Tres semanas de contenido perfectamente guardado en git y devolviendo 404 a cualquiera que entrase.
Cuando por fin me puse a mirar, la causa era ridícula. Pero tirando de ese hilo acabé destripando el pipeline entero, y ahí sí encontré cosas interesantes: 4812 minutos de compilación consumidos de los 3000 que da el plan gratuito, el 62% de las páginas del sitio generándose para nadie, y 102 de mis últimos 113 despliegues produciendo exactamente el mismo sitio byte a byte.
Esto es la autopsia. Con los números medidos, no estimados.
El síntoma: los posts existían y no existían
El primer diagnóstico fue trivial una vez supe dónde mirar. Los ficheros estaban en master. Las URLs daban 404. Así que el problema no era el contenido: era el despliegue.
La confirmación llegó de un sitio inesperado. Mi worker genera un pequeño manifiesto, /publish-queue.json, con la fecha del próximo post programado. Un curl bastó:
curl -s https://blog.marcosramirez.info/publish-queue.json
{"next":"2026-08-23T08:00:00.000Z","pending":60}
Esa fecha llevaba 18 días vencida. El manifiesto solo se regenera cuando un build termina bien, así que ahí estaba la fecha de defunción exacta: el último despliegue correcto fue el 22 de agosto.
Si tienes un sitio estático que se despliega solo, ponle un manifiesto así. Un JSON con la fecha del último build sirve para saber en un segundo si el pipeline está vivo, sin entrar en ningún panel.
La causa: una línea que faltaba
El post que rompió el build era la recopilación del 23 de agosto. En la cabecera tenía esto:
import Link from '@/components/Link.astro';
Y en el cuerpo, 37 componentes <NewsCard />. Sin importar.
MDX no perdona eso. Revienta con un Expected component NewsCard to be defined, astro build falla, y como en Cloudflare Workers Builds un build fallido no despliega nada, la web se quedó congelada en el estado del día anterior.
¿Por qué se coló? El draft de esa semana se creó por la mañana con la cabecera antigua, y la migración a tarjetas entró esa misma tarde. El script que monta la recopilación escribe la línea del import solo cuando crea el draft, nunca cuando le añade tarjetas después. Un caso de manual: el fichero cambió, la cabecera no.
El bucle: 4812 minutos de 3000
Aquí es donde se pone interesante, porque el fallo se alimentaba a sí mismo.
Mi worker tiene un cron cada 5 minutos que consulta el manifiesto y, si toca publicar, dispara un build. La lógica era esta:
const { next } = await res.json();
if (!next) return false;
return Date.now() >= Date.parse(next);
Lee bien lo que pasa cuando el build está roto. next se queda vencido para siempre, porque solo un build correcto lo actualiza. Y Date.now() >= Date.parse(next) devuelve true. Siempre.
288 disparos al día, durante 18 días. Un build fallido no puede actualizar el manifiesto que lo pararía: mordiéndose la cola perfectamente.
El resultado, en el panel: 4812 minutos consumidos de los 3000 del plan gratuito. Cloudflare te deja pasarte, cosa que agradezco, pero es un límite que no quieres tocar.

Esa captura es de un rato después, mientras escribía esto: 4839. La barra ya no cabe en su sitio y el contador sigue subiendo, porque cada arreglo que iba probando era otro build.
El arreglo es un escalón sin estado, derivado del propio tick del cron:
function allowedAt(at, overdueMs) {
if (overdueMs < 30 * 60 * 1000) return true; // recién vencido: cada tick
if (overdueMs < 6 * 60 * 60 * 1000) return at.getUTCMinutes() < 5; // 1 vez/hora
return at.getUTCHours() === 6 && at.getUTCMinutes() < 5; // atascado: 1/día
}
Simulado sobre las 288 marcas de un día con el build roto: 1 disparo en vez de 288. Y sin perder puntualidad en el caso normal, que es lo que importa.
La lección aquí no es el código. Es que un fail-open sin techo no es tolerancia a fallos, es una bomba de relojería. Tirar para adelante ante la duda está bien para no perderte una publicación. Tirar para adelante siempre, sin que nadie lo pare, te vacía la cuota.
Medir antes de tocar
Con el sitio ya vivo, me quedé mirando el tiempo de compilación. Y aquí hice lo único que de verdad importa: mirar el desglose real antes de optimizar nada.

Tres minutos y cuarto en total, repartidos así:
- Inicializando: 1m37. Provisionar el contenedor. No es mío, no puedo tocarlo.
- Clonando: 8s e instalando: 14s. Ya con caché, nada que rascar.
- Compilando: 1m12. Esto sí es mío. Es el único trozo sobre el que puedo hacer algo.
- Desplegando: 23s.
Mi primer instinto fue el caché de dependencias. Fui al panel a activarlo y ya estaba puesto:

Por eso la instalación tarda 14 segundos y no dos minutos. La optimización obvia ya estaba hecha, y de haber tirado por ahí habría perdido la tarde para nada.
En local, astro build tardaba 55,6 segundos y Astro te dice en qué se van:
[build] Building static entrypoints...
✓ Completed in 19.93s.
[build] ✓ Completed in 53.83s.
[build] 1218 page(s) built in 55.63s
Traducido: ±34 segundos compilando entrypoints con Vite y MDX, y ±20 generando las 1218 páginas. Dos palancas, y ninguna donde yo pensaba.
Palanca 1: MDX trae un interruptor y nadie lo usa
La integración MDX de Astro tiene una opción llamada optimize que la propia documentación recomienda cuando manejas muchos ficheros MDX. Colapsa los subárboles estáticos de cada documento en cadenas de HTML, así que hay menos nodos que compilar y que renderizar.
integrations: [mdx({ optimize: true })],
Una línea. De 55,6 a 48,8 segundos, un 12% menos, con la fase de entrypoints bajando de 33,9 a 28,3 segundos.
La documentación avisa de que puede generar HTML sin escapar y de que rompe los componentes personalizados pasados de forma dinámica. Así que no me fié: guardé el dist/ de antes, reconstruí, y comparé el texto visible y todos los href y src de las 1218 páginas.
Cero diferencias reales. Las únicas discrepancias eran variantes de escapado del mismo carácter (< frente a <), que el navegador pinta idéntico.
Ese es el trabajo de verdad, no la línea de configuración. Activar un flag es gratis; demostrar que no te ha roto nada es lo que te deja dormir.
Palanca 2: el 62% de las páginas no las quería nadie
De 1219 páginas generadas, esta era la distribución:
| Ruta | Páginas | Qué eran |
|---|---|---|
/tags/<x>/ | 596 | 434 con un solo post cada una |
/posts/<slug>/ | 297 | Páginas cuyo único contenido era un redirect 301 |
/categories/<x>/ | 25 | Duplicado en inglés de /categoria/ |
| El resto | ±300 | Los posts de verdad, paginación y estáticas |
Los 297 stubs de /posts/ eran lo más sangrante. Cada uno era una página prerenderizada completa cuyo cuerpo entero era Astro.redirect('/<slug>/', 301). Compatibilidad con URLs de la época Jekyll, resuelta de la forma más cara posible.
Todo eso se movió al worker, que es donde debía estar desde el principio:
const postsMatch = path.match(/^\/posts\/(.+?)\/?$/);
if (postsMatch) return redirect301(`/${postsMatch[1]}/`);
Mismo 301 para el visitante. Cero páginas de build.
Y las etiquetas: 434 de 598 tenían un único post. Eso no es una página de archivo, es contenido pobre que además ya estaba excluido del sitemap. Ahora una etiqueta necesita al menos dos posts para tener página propia, y las huérfanas se pintan sin enlace.
Resultado: 1218 páginas a 465, y el build de 48,8 a 36 segundos. Un 35% menos que el punto de partida.
El bug que solo aparece cuando ordenas el armario
Agrupando las etiquetas me encontré con algo que llevaba ahí sabe dios cuánto. Siete pares de nombres distintos producen el mismo slug: Desarrollo Web y desarrollo-web, MMO y mmo, Inteligencia Artificial e inteligencia-artificial.
Colisionaban en getStaticPaths, y una variante ganaba en silencio. La página /tags/inteligencia-artificial/ listaba 2 posts en vez de 36.
Nadie lo habría reportado nunca. Solo aparece cuando te pones a contar.
La comprobación que faltaba: no compilar si no ha cambiado nada
Aquí llegó la pregunta que lo cambió todo, y no fue mía: si no hay nada nuevo que generar, ¿por qué se compila?
El cron ya tenía su control. Pero hay un segundo disparador que no tenía ninguno: cada push a master. Con la integración de git, cualquier commit lanza un build completo, cambie lo que cambie.
Y en mi caso cambia mucho que no afecta al sitio: el commit diario que genera borradores, el borrador semanal de la recopilación, ajustes de workflows, de scripts, de documentación.
Lo medí. De los 113 pushes del último mes:
- 11 necesitaban build.
- 102 producían un
dist/idéntico byte a byte.
A ±3 minutos por build son ±306 minutos al mes recompilando exactamente el mismo sitio.
Antes de recomendar nada me aseguré de verificarlo, porque suponer es justo lo que me había metido en el lío inicial. Construí el sitio y saqué el SHA-256 de los 1075 ficheros del resultado. Toqué un borrador y reconstruí: mismo hash. Luego toqué de golpe la documentación, los scripts de CI, un workflow y el hook de pre-commit, y reconstruí: el mismo hash otra vez.
La solución es una función que Workers Builds trae de serie y que viene desactivada: las rutas de vigilancia de compilación. Por defecto incluye * y excluye nada, o sea, compila siempre. En Settings → Build → Build watch paths se le dice qué rutas ignorar, y un push que solo las toca no lanza build.
Así quedó lo mío: la inclusión intacta y diez exclusiones, que son justo las carpetas donde vive todo lo que no se publica.

El orden de evaluación importa y no es el que parece: primero se descartan las exclusiones y lo que sobrevive se contrasta contra las inclusiones. Por eso el comodín de la izquierda no pisa a la lista de la derecha.
Dos avisos por si lo copias. Nada de comodines alegres tipo *.md: en mi repositorio hay un .md dentro de assets/ que sí se publica, así que los ficheros de documentación van listados uno a uno. Y si publicas por fecha, comprueba que tu disparador programado sigue funcionando: en mi caso el cron llama a un Deploy Hook, que es un build manual sin diff contra el que casar rutas, pero es exactamente el tipo de detalle que te devuelve a los 18 días de silencio.
Estas cosas se automatizan bien y se rompen fácil. Es el tipo de infraestructura que monto para empresas que despliegan a diario y no quieren enterarse de que llevan tres semanas caídas por un aviso de un lector.
Lo que me llevo
Cuatro cosas, y ninguna es sobre Cloudflare ni sobre Astro.
Un pipeline en verde no significa que el producto funcione. Todos mis workflows terminaban correctamente. Ninguno comprobaba lo único que importaba: que la web sirviera lo que el repositorio decía tener.
El fail-open necesita techo. “Ante la duda, despliega” es una política sensata. “Ante la duda, despliega cada 5 minutos para siempre” es una factura.
Mide antes de optimizar. Iba a por el caché de dependencias, que ya estaba puesto. El tiempo real estaba en compilar MDX y en generar páginas que nadie pedía.
La optimización más rentable es no hacer el trabajo. Recorté un 35% del tiempo de compilación con dos cambios reales. Pero eliminar 102 compilaciones inútiles al mes ahorra diez veces más que cualquier micro-optimización del build.
Y una quinta, que no es técnica: la automatización no te sustituye, te espera. Todo el tinglado siguió generando contenido durante mi baja sin fallar un día. Lo único que hacía falta era alguien mirando, y ese alguien no estaba.
Con esto vuelvo, poco a poco. La vista todavía manda sobre las ganas, así que iré publicando al ritmo que pueda, sin prometer una cadencia que luego no cumpla.
Preguntas frecuentes
¿Cómo sé si mi sitio estático lleva tiempo sin desplegarse?
Genera en el build un fichero pequeño con la fecha, por ejemplo un build-info.json con el timestamp, y consúltalo con curl. Si la fecha no avanza, el pipeline está roto aunque el repositorio parezca sano. Es la comprobación más barata que existe y te ahorra semanas de silencio.
¿Es seguro activar `optimize` en la integración MDX de Astro?
En la mayoría de casos sí, pero verifícalo. La documentación avisa de que puede generar HTML sin escapar y de que rompe los componentes personalizados que se pasan de forma dinámica. Si renderizas con <Content /> sin prop components y los componentes van importados dentro de cada .mdx, es el escenario que el optimizador maneja bien. Aun así, guarda la salida anterior y compárala.
¿Las rutas de vigilancia afectan a los despliegues programados?
Las rutas de vigilancia filtran builds disparados por un push, comparando los ficheros que cambian. Un Deploy Hook es un build manual sin diff, así que no le aplica el filtro. Aun así compruébalo una vez tras activarlo: si tu publicación programada depende del hook y se filtrara, los posts dejarían de salir sin avisar.
¿Cuántos minutos de compilación da el plan gratuito de Cloudflare Workers?
3000 minutos al mes, con una compilación simultánea y un tope de 20 minutos por build. El plan de pago sube a 6000. El límite es blando: yo llegué a 4812 sin que me bloquearan, pero no es algo con lo que quieras jugar.
Fuentes
- Workers Builds: límites y precios (Cloudflare)
- Workers Builds: rutas de vigilancia de compilación (Cloudflare)
- Workers Builds: caché de compilación (Cloudflare)
- Integración MDX de Astro: opción optimize
- Referencia de configuración de Astro
Compártelo si te ha resultado útil.
¿Cuánto tiempo tardarías tú en enterarte de que tu web lleva tres semanas sin actualizarse? Cuéntame.
Y… ahora toca mirar qué otra cosa lleva rota desde hace meses sin que me haya dado cuenta.
P.D. Empecé a escribir esto y, al generar la imagen de cabecera, el generador me devolvió un PERMISSION_DENIED por facturación deshabilitada. ¿Motivo? Se me había caducado la tarjeta de crédito. Osea que mientras escribía un post sobre no enterarme de que algo llevaba semanas roto, me encontré con otra cosa que llevaba semanas rota xD
De momento lo he resuelto metiendo Cloudflare Workers AI como generador de imágenes, que es gratis, no pone marca de agua encima y resulta que ya lo tenía pagado en forma de no pagar nada. Pero eso, con sus números y sus tropiezos, será otro post.
Artículos relacionados
El cron de GitHub Actions falla: lo arreglé con Cloudflare
Programé un post para las 8:30 de la mañana y a las 9 seguía sin aparecer en la web. El culpable no era mi código ni la zona horaria: era el scheduler de cron de GitHub Actions, que lleva meses estrangulando las tareas programadas. Te cuento por qué pasa, qué opciones tenía sobre la mesa y cómo lo resolví moviendo el reloj a un Cron Trigger de Cloudflare que dispara el despliegue por API.
Cómo un cron cada 15 minutos casi me cuesta 12 dólares en GitHub
El jueves el post del Prime Day no se publicó. Y no fue un fallo del código, ni de Cloudflare, ni de Astro. Fue que me había quedado sin minutos de GitHub Actions sin enterarme, porque tenía un cron reconstruyendo el blog entero cada 15 minutos, las 24 horas, todos los días. El medidor se plantó en 12 dólares (que resulta que no me cobran, pero que sí me hicieron sobrepasar el límite) y el blog dejó de actualizarse. Aquí te cuento qué tenía mal montado, cómo lo arreglé, y el truco que uso ahora para que el despliegue solo se ejecute cuando de verdad hay algo que publicar, en vez de gastar a ciegas cada cuarto de hora.
Liberar RAM en Windows: el ajuste viral de SysMain y Prefetch
Abres el administrador de tareas, miras la memoria y te llevas un susto: Windows tiene varios gigas de tu RAM en caché. Hay un ajuste que se ha hecho viral porque a algunos usuarios les ha liberado hasta 5 GB desactivando dos servicios, SysMain y Prefetch. Pero antes de que toques nada a ciegas, hay que entender una cosa: esa RAM en caché casi nunca te la están robando, te la están adelantando para abrir antes tus programas. Aquí te explico qué hacen de verdad SysMain (el antiguo Superfetch) y Prefetch, por qué el truco ayuda en unos equipos y empeora otros, cómo saber si te conviene, cómo desactivarlos paso a paso y, sobre todo, cómo volver atrás si tu PC va peor.