Cómo un cron cada 15 minutos casi me cuesta 12 dólares en GitHub
El jueves por la mañana abrí el blog y el post del Prime Day no estaba. Lo tenía programado, con su fecha puesta, todo en orden. Y no salió. Mi primera reacción fue la de siempre: “algo se ha roto en el código”. Pues no. No se había roto nada. Lo que pasó es que me había quedado sin minutos de GitHub Actions, y ni me había enterado.
Te cuento el desastre entero, porque es uno de esos fallos tontos que no ves venir hasta que te muerde, y porque la solución me ha dejado un truco que merece la pena.
La bomba de relojería que yo mismo había montado
Para que entiendas el lío, primero cómo funciona esto por dentro. El blog está hecho con Astro y vive en Cloudflare Workers. Es un sitio estático: cada vez que publico algo, hay que reconstruir todas las páginas y volver a desplegarlas. Ese build lo hacía GitHub Actions en cada push, que es lo normal y lo correcto.
El problema venía de los posts programados. Yo escribo un artículo, le pongo fecha de dentro de tres días, y quiero que salga solo ese día a las 08:30. Pero un sitio estático no “sabe” que ha llegado la hora: hay que reconstruirlo para que el post nuevo aparezca. ¿Mi solución de entonces? Un cron en Cloudflare que cada 15 minutos le decía a GitHub Actions: “reconstruye y despliega, por si acaso ha llegado la hora de algún post”.
Cada 15 minutos. Las 24 horas. Todos los días. Saca la cuenta: son 96 reconstrucciones diarias, casi 3.000 al mes. Y la inmensa mayoría no publicaban nada nuevo, porque no había nada que publicar a esa hora. Estaba reconstruyendo el blog entero, una y otra vez, para nada.
Y por si fuera poco, el workflow estaba mal partido en dos trabajos que compilaban el sitio por separado. Es decir, cada una de esas 96 ejecuciones diarias hacía el build no una, sino dos veces. La bomba estaba armada y yo le había puesto pilas nuevas.
La letra pequeña: 12 dólares que ni me cobran
Aquí entra el detalle que mucha gente no tiene presente. GitHub Actions es gratis… hasta cierto punto. En un repositorio privado, tienes ±2.000 minutos gratis al mes. Pasado eso, o pagas el exceso, o se te bloquean los trabajos, según el límite de gasto que tengas puesto.
Y este blog vive en un repo privado. Con 96 builds dobles al día, cada uno tardando un par de minutos, échale la cuenta: me ventilé los 2.000 minutos sin despeinarme. Y aquí está lo curioso. Mira el panel:

¿Ves esos 12,21$ de uso medido? Pues no me los van a cobrar. Justo al lado está el uso incluido: 12,22$ de descuento que tapan exactamente ese consumo. El próximo pago es un guion. Cero. No hay factura.
Pero que no haya factura no significa que no pasara nada, ni mucho menos. Lo que sí hice fue sobrepasar el límite incluido, y cuando llegas ahí con el límite de gasto a cero, GitHub no te cobra: te bloquea los trabajos. Sin trabajos no hay build. Sin build no hay despliegue. Y sin despliegue, el Prime Day se quedó sin publicar esta misma mañana.
Lo mejor de todo es que el aviso llega por correo, perdido entre mil notificaciones, así que ni lo vi. Me enteré cuando fui a mirar por qué narices no había salido un post. El clásico: el sistema lleva días avisándote y tú mirando para otro lado.
El arreglo rápido
Lo primero fue parar la sangría. Tres cambios directos:
- Un solo build por ejecución. Junté los dos trabajos del workflow en uno. Donde antes compilaba dos veces, ahora una. La mitad de minutos de golpe.
- Bajar la frecuencia del cron, de cada 15 minutos a cada hora. Esta fue mi reacción en caliente: menos ejecuciones, menos builds. Tiene trampa, y la retomo enseguida, porque resulta que la frecuencia del cron no era el problema.
- Otros automatismos, a dieta. Tenía un segundo flujo que sincronizaba ideas cada hora. No necesita correr cada hora ni de lejos. Lo bajé a una vez al día.
Y mientras tanto, como los minutos seguían bloqueados ese mes, desplegué a mano desde mi ordenador con un pnpm build y un despliegue directo a Cloudflare. Problema inmediato resuelto. Pero esto seguía siendo un parche. Reconstruir el blog entero por horario, haya o no algo que publicar, sigue siendo tirar minutos a la basura.
El truco: que el cron solo despliegue si de verdad toca
Aquí está la parte que me gusta. La pregunta correcta no era “¿cada cuánto reconstruyo?”, sino “¿por qué reconstruyo si no hay nada nuevo que publicar?”.
El cron de Cloudflare apenas cuesta: corre en su infraestructura y no tiene tarifa aparte. Cada ejecución cuenta como una petición más del Worker, pero aunque lo dispares cada pocos minutos es una gota en el cubo (el plan gratuito da 100.000 peticiones al día). Lo que de verdad cuesta dinero es la reconstrucción en GitHub Actions. Así que la idea es sencilla: que el trabajo barato (el cron) compruebe si hay algo que hacer, y solo entonces dispare el trabajo caro (el build).
¿Cómo sabe el cron si toca publicar? Pues haciendo que cada build deje escrito, en un pequeño archivo, la fecha del próximo post pendiente. Una vez por build, el sitio genera un publish-queue.json con algo tan tonto como esto:
{ "next": "2026-06-19T06:30:00.000Z", "pending": 103 }
Fíjate en la hora: ese 06:30Z es UTC, o sea, las 08:30 de la península en verano (+02:00). Trabajar siempre en UTC, tanto aquí como en el cron, es lo que te ahorra los líos clásicos con el horario de verano: las fechas se comparan en UTC y no hay que pensar en zonas horarias.
Y el cron, cada vez que se despierta, en lugar de reconstruir a ciegas, lee ese archivo (que es un fichero estático ya desplegado, o sea, gratis de leer) y decide:
async function shouldRebuild(env) {
const res = await env.ASSETS.fetch(new Request(URL_DEL_JSON));
if (!res.ok) return true; // si falla, mejor desplegar de más
const { next } = await res.json();
if (!next) return false; // no hay nada pendiente: no gastes
return Date.now() >= Date.parse(next); // ¿ya venció la fecha? entonces sí
}
La lógica completa cabe en la cabeza: si no hay ningún post pendiente, no hace nada. Si lo hay pero su fecha aún no ha llegado, tampoco. Solo cuando la fecha del próximo post ya ha pasado, dispara la reconstrucción. Y al reconstruir, ese post pasa a estar publicado y el publish-queue.json se regenera apuntando al siguiente. El ciclo se cierra solo.
Y aquí está la vuelta de tuerca que se me escapó al principio. Una vez que existe este filtro, la frecuencia del cron deja de importar para el coste. Da igual que se ejecute cada hora o cada 5 minutos: si no toca publicar, no gasta nada, porque lo que cuesta es el build, y el build solo salta cuando un post ha vencido. Así que hice justo lo contrario de mi parche inicial: en vez de bajar el cron, lo subí a cada 5 minutos. ¿Para qué? Para que un post programado salga puntual, con cinco minutos de margen como mucho, en vez de hacerle esperar hasta una hora. Puntualidad casi gratis.
Fíjate en el detalle del return true cuando algo falla. Es a propósito. Prefiero un despliegue de más a que un post no salga por un fallo tonto leyendo el archivo. Ante la duda, que el sistema falle hacia publicar, no hacia callarse. Es la diferencia entre un parche y un sistema en el que confías.
El resultado: el cron se ejecuta cada 5 minutos, gratis, pero el blog solo se reconstruye el día que de verdad hay un post que sacar. Antes, cada ejecución del cron era un build a la basura; ahora casi ninguna lo es. Frecuencia alta, gasto mínimo, y los posts puntuales.
El flujo completo, paso a paso
Para que se vea entero y cómo encaja cada pieza:
- El build emite un manifiesto. Cada vez que el sitio se compila, un endpoint genera el
publish-queue.jsoncon la fecha del próximo post pendiente (el de fecha futura más cercana). Es un archivo estático más, desplegado junto al resto del sitio. - El cron de Cloudflare se despierta cada 5 minutos (en UTC, como todos los crons de Cloudflare). Casi sin coste, en su infraestructura. Lo único que hace de entrada es leer ese
publish-queue.json. - Decide. Si no hay ninguna fecha pendiente, se vuelve a dormir. Si la hay pero aún no ha llegado, también. Solo si la fecha del próximo post ya venció, pasa al paso 4.
- Dispara el build caro. Llama a GitHub Actions con un
workflow_dispatchy le pide reconstruir y desplegar. Aquí, y solo aquí, se gastan minutos de Actions. - El ciclo se cierra solo. Al reconstruir, el post programado pasa a estar publicado y el
publish-queue.jsonse regenera apuntando al siguiente de la cola. Vuelta al paso 1.
Lo bonito es el reparto de trabajo: la parte que se ejecuta sin parar (el cron, cada 5 minutos) apenas cuesta y solo lee un archivito; la parte cara (el build) se ejecuta contadas veces, justo cuando hay algo que publicar. El trabajo barato delante, el caro detrás y bajo demanda.
Te dejo el código completo (el Worker con su scheduled, el endpoint del publish-queue.json, el workflow de un solo build y el cron de wrangler), ya limpio para que lo copies y lo adaptes a lo tuyo: el despliegue inteligente al completo, en este gist.
El epílogo: si total, ya estaba en Cloudflare
Y aquí la historia da un giro. Con los minutos de Actions bloqueados hasta primeros de julio, me quedaba la perspectiva de publicar a mano cada vez durante casi dos semanas. Un parche encima de otro parche. Y mientras desplegaba por enésima vez desde mi terminal, me asaltó la pregunta tonta que debí hacerme mucho antes: ¿y por qué el build tiene que vivir en GitHub?
Porque no tiene. Cloudflare también compila. Tiene su propio CI, Workers Builds, con 3.000 minutos de build gratis al mes, que son más que los 2.000 de GitHub. Mi sitio ya vivía en Cloudflare, el cron ya estaba en Cloudflare, el dominio estaba en Cloudflare. ¿Qué pintaba GitHub en medio, aparte de guardar el código?
Así que lo moví entero:
- Push a master → Workers Builds compila y despliega. GitHub Actions fuera de la ecuación.
- El cron (ese paso 4 que llamaba a GitHub con un
workflow_dispatchy un token) ahora hace un POST a un Deploy Hook de Cloudflare: una URL secreta que, al recibir la llamada, dispara el build. Sin PAT, sin cabeceras, sin depender de una cuenta ajena. Una línea de código.
Y cuando digo una línea, es una línea. El Deploy Hook es una URL que generas en el panel de Workers Builds y guardas como secreto (aquí, DEPLOY_HOOK_URL). Un POST a esa URL lanza un build, y no necesita cabecera de autenticación porque la propia URL ya es el secreto. Así que aquel triggerRebuild que antes era un bloque con la API de GitHub, sus cabeceras y el token, se quedó en esto:
async function triggerRebuild(env) {
await fetch(env.DEPLOY_HOOK_URL, { method: 'POST' }); // y a construir
}
El cron solo la llama cuando su gate shouldRebuild dice que toca. Se acabó el token de GitHub dando vueltas por el Worker.
GitHub se queda como lo que de verdad necesito de él: un armario donde guardar el código. Todo lo que se mueve (compilar, desplegar, programar) lo mueve Cloudflare, bajo un mismo techo.
Y la ironía final: el susto de los 12 dólares me ha dejado un flujo más simple que el de antes de empezar a complicarlo. A veces el mejor refactor es el que te obliga a hacer un problema que creías que era de otra cosa.
Lo que me llevo de todo esto
Llevo treinta años desarrollando y sigo cayendo en lo mismo: resolver un problema con fuerza bruta porque es lo rápido, y olvidarme de que la fuerza bruta tiene una factura. “Reconstruyo cada 15 minutos por si acaso” funcionaba. También funcionaba quemar los minutos hasta quedarme bloqueado, sin que nadie me avisara a la cara.
La lección de verdad no es “vigila tu factura de GitHub”, que también. Es que cuando montes algo automático, el coste no desaparece porque no lo mires: solo se hace invisible. Y lo invisible es justo lo que te muerde. Mover el trabajo barato delante del caro, para que el caro solo se ejecute cuando hace falta, no es ninguna genialidad. Es sentido común que se me había olvidado aplicar.
Si gestionas despliegues, automatismos o cualquier cosa que se ejecute sola en bucle, este es justo el tipo de fuga que reviso y tapo cuando monto infraestructura para alguien. Casi siempre hay un cron corriendo de más en algún rincón, gastando por la cara.
Preguntas frecuentes
¿Cuántos minutos gratis tiene GitHub Actions?
En repositorios privados tienes alrededor de 2.000 minutos gratis al mes (la cifra exacta depende de tu plan). En repositorios públicos, GitHub Actions es gratuito sin ese límite. El detalle importante es que, al superar los minutos en un repo privado, GitHub puede cobrarte el exceso o bloquear directamente los trabajos según tu límite de gasto configurado.
¿Por qué dejó de publicarse el blog?
Porque me quedé sin minutos de GitHub Actions. El blog es estático y necesita una reconstrucción para que aparezca un post programado. Esa reconstrucción la hace GitHub Actions, y al agotarse los minutos, GitHub bloqueó los trabajos. Sin trabajos no hay build, y sin build el post nuevo no llega a desplegarse aunque su fecha ya haya pasado.
¿Cómo evito reconstruir un sitio estático sin necesidad?
Haciendo que el disparador barato compruebe si hay trabajo antes de lanzar el build caro. En mi caso, cada build deja un archivo con la fecha del próximo post pendiente, y un cron gratuito en Cloudflare solo dispara la reconstrucción cuando esa fecha ya ha vencido. El resto del tiempo no gasta nada. Así pasas de reconstruir cada pocos minutos a hacerlo solo cuando de verdad toca publicar.
¿Por qué no usar el scheduler de GitHub directamente?
Porque el cron schedule de GitHub no es puntual: estrangula las ejecuciones y abre huecos de una o dos horas, así que los posts no salían a su hora. El cron de Cloudflare sí cumple el horario, y casi no cuesta. Por eso el horario lo marca Cloudflare y a GitHub solo se le pide el build cuando hace falta.
¿El cron de Cloudflare cuesta dinero?
Los Cron Triggers no tienen una tarifa aparte, pero cada ejecución cuenta como una petición más de tu Worker. En el plan gratuito tienes 100.000 peticiones al día, así que un cron cada 5 minutos (288 al día) ni se nota: es virtualmente gratis. Por eso conviene tenerlo frecuente, para que los posts salgan puntuales: como el build solo salta cuando toca, subir la frecuencia del cron mejora la puntualidad sin apenas coste. Y ojo: los crons de Cloudflare se programan en UTC, no en tu hora local.
Compártelo si te ha resultado útil, sobre todo con quien tenga automatismos corriendo en bucle y no haya mirado la factura últimamente.
¿Te ha pasado algo parecido, una fuga tonta gastando dinero a tu espalda? Cuéntame.
Y… el cron ya no corre por correr. Ahora corre con un motivo.
Artículos relacionados
De Cloudflare Pages a Workers con Astro: la guerra real
Cuento cómo migré marcosramirez.dev de Cloudflare Pages a Cloudflare Workers en dos días: por qué fue necesario, qué salió mal en cada intento, qué configuraciones rotaron sin funcionar y cómo quedó al final. Si estás usando Astro con el adaptador de Cloudflare y tienes rutas de API con SSR, esto te va a ahorrar tiempo.
Comandos básicos de Proxmox: gestión de LXC y VMs vía CLI
Aprende a usar los comandos pct y qm para gestionar contenedores LXC y máquinas virtuales en Proxmox desde la línea de comandos. Esta guía completa cubre la creación de contenedores y VMs, gestión de plantillas, configuración de red e IPs estáticas, modificación de recursos como CPU y memoria, redimensionamiento de discos con advertencias importantes, montaje de directorios con bind mounts y virtio-fs, y comandos del sistema como backups con vzdump. Incluye una comparativa de comandos pct vs qm, consejos prácticos con rangos de IPs para organizar tu Home Lab, y soluciones a problemas comunes como los que afectaron a mi instalación de Jellyfin con NFS.
Racks y mini racks para Home Lab: guía de compra completa
Guía completa sobre racks y mini racks para tu Home Lab según espacio y presupuesto. Compara mini racks de escritorio (2-6U), racks de pared (6-12U), racks de 12-18U y racks completos de 42U. Incluye opciones para Mini PCs y SBCs, comparativas de modelos y dónde comprar.