DevLog#github actions#cron

Cron jobs diarios con GitHub Actions + Next.js: cómo armamos los flujos automáticos de esta web sin romper en producción

Necesitábamos correr 3 tareas todos los días: sync de datos externos, review con IA y notificaciones por mail. Probamos Vercel Cron, hosting cron y GitHub Actions. Esto es lo que aprendimos, incluyendo el bug del re-procesamiento que tardamos dos semanas en cazar.

Franco Vassallo
Franco Vassallo
Head of Operations
5 de junio de 2026 11 min de lectura

Armar un flujo que corra todos los días en una web Next.js no es solo poner un cron schedule en algún lado. Hay decisiones reales — dónde corre el scheduler, cómo se autentica contra tu app, cómo manejás el timeout del host, cómo evitás re-procesar lo mismo dos veces, qué pasa cuando una llamada falla. Para esta web evaluamos cuatro opciones (Vercel Cron, GitHub Actions, cron del hosting y servicios SaaS dedicados) y terminamos con GitHub Actions disparando endpoints autenticados del propio sitio. En este artículo contamos el porqué, mostramos el workflow real y compartimos el bug del re-procesamiento que tardamos dos semanas en cazar porque "técnicamente" todo se veía OK.

3
flujos diarios corriendo en producción
$0
costo mensual del scheduler (GitHub Actions free tier)
2 sem
que tardamos en cazar el bug de re-procesamiento

El contexto: qué necesitábamos correr

Tres flujos distintos, cada uno con su frecuencia y sus complicaciones:

  • Sync de datos externos (diario, en horario de baja carga): consolidar listados que llegan de distintas fuentes públicas, detectar lo nuevo y desactivar lo que ya no aparece.
  • Review con IA (después del sync): pasar cada item nuevo por un LLM que limpia el contenido, extrae atributos clave e infiere categorías. Si la confianza del modelo es baja, queda en borrador esperando aprobación humana.
  • Notificación periódica (diaria, en horario laboral): para cada destinatario con novedades acumuladas, armar un resumen y mandarlo por mail.

Los tres comparten una propiedad importante para esta historia: tardan más que el timeout de un request HTTP típico. El sync recorre muchas empresas; el review hace una llamada a LLM de 5-10 segundos por empleo; la notificación arma mails con plantilla y los manda con sleep entre cada uno. Si los hacemos como un endpoint normal y lo dejamos correr 5 minutos, el proxy nos corta a los 60-120s y perdemos todo el progreso.

Las cuatro opciones que evaluamos

Antes de elegir, miramos cada opción honestamente con su pro/contra real:

Característica
IntegradoVercel Cron
ExternoGitHub Actions
Server-sideCron del hosting
ServicioSaaS externo (EasyCron, etc.)
Setup inicial
vercel.json + deploy.github/workflows + secretcPanel UI o crontab -eCrear cuenta + verificar
Costo
Free hasta cierto límiteFree 2.000 min/mesGratis si ya pagás hosting$5-30/mes según uso
Funciona en cualquier host
Solo VercelSi tu host lo soporta
Logs accesibles
Dashboard de VercelGitHub Actions UIVariable según hostDashboard propio
Retry automático
NoConfigurable No
Visible para todo el equipo
Si tienen acceso a VercelSi tienen acceso al repoSolo quien tiene SSHCuenta separada
Trigger manual
Sí, desde dashboardSí, workflow_dispatchHay que SSHSí, desde dashboard

Para nuestro caso particular, Vercel Cron quedó descartado porque la app vive en un hosting tradicional Node (no Vercel). El cron del hosting funcionaba pero quedaba enterrado en un panel donde nadie del equipo iba a mirar logs. Los SaaS externos agregaban una cuenta más para mantener y un mes de free trial que después se volvía cargo.

GitHub Actions ganó por tres razones concretas: es gratis para repos privados hasta 2.000 minutos al mes (suficiente para tres workflows diarios), los logs viven en la misma UI donde ya hacemos PRs y reviews, y permite disparar el workflow manualmente con un click cuando hace falta — para debug en vivo eso vale oro.

La arquitectura: GitHub → endpoint Next.js → Supabase

La separación de responsabilidades quedó así:

  • GitHub Actions es solo el scheduler. No hace lógica de negocio. Su trabajo es despertar a la hora configurada, pegarle al endpoint con el secret correcto, parsear el response y loggear el resultado.
  • El endpoint Next.js es donde vive toda la lógica. Valida el secret, hace la query a la base, procesa lo que tenga que procesar y devuelve un JSON con métricas.
  • La base de datos es la fuente de verdad. El endpoint marca un timestamp de procesado en cada fila que toca para no volver a procesarla en el mismo run.

El beneficio principal de esta separación: si mañana cambiamos de hosting (Vercel, Railway, Fly.io), el workflow de GitHub Actions no se entera — solo cambia la URL del endpoint en una variable.

Autenticación: en el header, nunca en el query

El endpoint del cron tiene que rechazar todo lo que no sea el scheduler. Para eso conviene un secret pre-compartido (llamémoslo JOB_TOKEN) que vive en dos lugares:

  • En las variables de entorno del hosting, donde el endpoint las lee para comparar.
  • En los secrets del repo de GitHub, donde el workflow las inyecta al curl.

La parte importante — y donde es fácil meter la pata — es cómo se manda ese secret al endpoint. La forma incorrecta y muy tentadora es pasarlo por query param:

bash
# ❌ NO HAGAS ESTO
curl "https://tuapp.com/api/jobs/diario?token=mi-secret-super-largo"

El problema es que los query params se loggean en todas partes: el log de acceso de nginx, los logs del proxy/CDN intermedio, el referer del browser si hay un redirect, los analytics. Para un secret, eso es comprometerlo sin saberlo.

La forma correcta es mandarlo en el header Authorization:

bash
# ✅ Asi
curl -H "Authorization: Bearer $JOB_TOKEN" \
  "https://tuapp.com/api/jobs/diario"

Los headers no se loggean por defecto, no aparecen en analytics, no leakean por referer. El validador del endpoint acepta el secret solo por header, y rechaza explícitamente el query param aunque le llegue — defensa en profundidad por si alguien futuro intenta "facilitar" el flow.

El patrón de batching: por qué no podés "solo correr todo"

El timeout de nginx en la mayoría de los shared hostings está entre 60 y 120 segundos. Si tu cron tarda más que eso, vas a recibir un 504 Gateway Timeout cuando el proxy se canse de esperar. El endpoint pudo haber terminado bien — vos no te enterás.

La solución es invertir el flujo: en vez de hacer "una llamada larga que procesa todo", el endpoint procesa un batch chico que cabe cómodo dentro del timeout, y el workflow lo llama N veces hasta que devuelva remaining: 0.

Concretamente, el patrón queda así:

  • Cada invocación del endpoint procesa un item por vez (que internamente puede tener varias subtareas, manejadas en paralelo con un cap chico de concurrencia para que entre cómodo dentro del timeout del proxy).
  • El workflow itera varias decenas de veces en un mismo run, con un cap de timeout-minutes generoso para no quedar colgado eternamente si algo se descalibra.
  • Cada respuesta incluye remaining. Cuando llega a 0, el workflow corta y reporta totales.
yaml
# Workflow simplificado del patron
name: Job diario
on:
  schedule:
    - cron: '0 8 * * *'   # ejemplo: 8:00 UTC
  workflow_dispatch:      # permite disparo manual

jobs:
  run:
    runs-on: ubuntu-latest
    timeout-minutes: 50
    steps:
      - name: Trigger endpoint
        env:
          JOB_TOKEN: ${{ secrets.JOB_TOKEN }}
        run: |
          set -e
          for i in $(seq 1 30); do
            response=$(curl -sS -L -m 180 \
              -H "Authorization: Bearer $JOB_TOKEN" \
              "https://tuapp.com/api/jobs/diario")
            remaining=$(echo "$response" | grep -oE '"remaining":[0-9]+' | cut -d: -f2)
            if [ "${remaining:-0}" = "0" ]; then break; fi
          done

El bug del re-procesamiento (la parte interesante)

Acá viene la parte que tardamos dos semanas en cazar y que motivó este artículo. En algún momento notamos que el job diario procesaba el doble o el triple de items de lo que esperábamos. El número subía y bajaba aleatoriamente. Los logs decían "OK" todas las veces, no había errores. El consumo de tokens del LLM se nos disparó sin razón aparente.

La causa era esta: el endpoint marcaba un timestamp de procesado (processed_at = now()) al terminar cada item, y la query del próximo batch era "dame todos los items con processed_at IS NULL OR processed_at < X". El problema era qué valor le poníamos a X.

En la versión bugueada, cada batch del endpoint usaba X = startedAt calculado en el momento de cada invocación. Resultado: el batch 1 procesaba items con processed_at < T0. El batch 2 venía 90 segundos después con X = T0 + 90s, lo que incluía a los items que el batch 1 ya había marcado con processed_at = T0 + (algunos segundos). Un item procesado en el batch 1 era candidato legítimo del batch 2. Y del batch 3. Y así.

Solución: fijar el cutoff UNA SOLA VEZ al inicio del workflow y pasarlo como query param a cada batch. El endpoint usa ese cutoff (no su now()) para la query. Un item que se procesó en el batch 1 deja de calificar a partir del batch 2 porque su processed_at ya es posterior al cutoff fijo.

bash
# Fijar el cutoff UNA SOLA VEZ al inicio
WORKFLOW_START=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)

for i in $(seq 1 30); do
  # Pasar el cutoff como query: el endpoint lo usa para la query SQL
  curl -H "Authorization: Bearer $JOB_TOKEN" \
    "https://tuapp.com/api/jobs/diario?since=$WORKFLOW_START"
done

Otros detalles que importan en producción

Algunas decisiones más pequeñas pero que aprendimos a fuerza de tropezones:

Redirects con auth

Algunos hostings redirigen 307 entre el dominio temporal del deploy y el dominio definitivo. En ese caso el curl normal pierde el header Authorization en el redirect. La flag --location-trusted preserva el header — segura solo cuando origen y destino son del mismo dominio que controlás. Si no, podés filtrar el secret a otro server.

Rate limits externos

El review con IA pasa por un proveedor de LLMs y, como cualquier API externa, tiene su límite de tokens por minuto en cada tier. La regla es simple: medí cuánto consume cada item promedio, calculá cuántos entran en la ventana, y dejá un sleep entre llamadas para no superar el cap. Más lento pero confiable. Mejor dormir que pelearse con 429 todo el día.

Idempotencia del lado del endpoint

Aunque el workflow esté bien hecho, asumir que el cron nunca va a re-disparar el mismo batch es ingenuo. GitHub Actions puede reintentar si el runner se cae. El endpoint tiene que estar listo para ver el mismo ?since= dos veces y dar el mismo resultado.

Trigger manual con workflow_dispatch

Agregar workflow_dispatch: al schedule te da un botón "Run workflow" en la UI de GitHub Actions. Cuando algo falló y querés re-correrlo a las 3 de la tarde sin esperar al próximo schedule, eso vale por sí solo. Ponelo siempre.

Pause sin borrar

Cuando una parte del workflow rinde mal (en nuestro caso, la review con IA tenía falsos positivos que hacían descartar empleos buenos), tentación de borrar el step. No lo borres — agregarle if: false lo pausa pero deja el código a la vista. Cuando vuelvas a tocarlo, no tenés que reescribir desde cero ni acordarte de cómo era. Un comentario arriba con la fecha y la razón cierra el círculo.

Checklist para arrancar tu propio cron

Si estás por armar un cron diario en tu app, estos son los chequeos que te ahorran descubrir cada bug en producción:

Característica
Antes de deployCheck
Pasa esto en prodSi fallás esto
El secret va en header, no en query
curl -H "Authorization: Bearer X"Token loggeado en accesos del proxy
El endpoint es idempotente
Mismo ?since= = mismo resultadoRe-procesamiento, costos disparados
El batch cabe en el timeout del host
~50% del timeout máximo504 silenciosos, progresos perdidos
workflow_dispatch está activado
Botón "Run workflow" disponibleNo podés debuggear hasta el próximo schedule
Hay un cap de iteraciones
for i in seq 1 30Loop infinito si la query nunca llega a 0
El error de un batch tira el workflow
set -e + exit 1 en HTTP != 200Workflow "verde" mientras todo falla
Logs accesibles para todo el equipo
GitHub Actions UI o equivalenteSolo una persona puede debuggear

Lo que aprendimos

Tres lecciones nos quedaron de hacer esto en producción y verlo romperse:

Primera: el scheduler es la parte fácil. Cualquier opción de cron (GitHub Actions, Vercel, hosting, SaaS) hace su trabajo en cuanto la configurás bien. El 90% de los bugs no son del scheduler — son del endpoint que el scheduler llama. Idempotencia, autenticación, manejo de timeout, manejo de fallos parciales: todo eso vive en el endpoint, no en el cron.

Segunda: los logs visibles cambian todo. Cuando pasamos del cron del hosting (logs en archivos rotantes que nadie miraba) a GitHub Actions (logs en la UI que cualquiera del equipo abre con un click), la velocidad para detectar problemas se multiplicó. Lo que antes tardaba semanas en aparecer hoy se ve al día siguiente. El costo de tener "buen tooling de observabilidad" no es la herramienta — es no tenerla y enterarte de los problemas tres semanas después.

Tercera: los flujos automáticos necesitan disciplina de batching. El instinto cuando armás un cron es "hago todo de una". El instinto correcto es "hago un batch chico y vuelvo a llamarme". La diferencia entre ambas formas no se nota mientras todo va bien — se nota el día que el host cambia el timeout, el día que crecen los datos, el día que tu LLM provider tiene una caída momentánea. El batching te salva esos días.

FAQ

Preguntas frecuentes

Para repositorios públicos es 100% gratis sin límite. Para repos privados, GitHub te da 2.000 minutos al mes en el plan free. Un workflow que corre 5 minutos al día consume ~150 minutos al mes — bien dentro del límite con margen para varios workflows en paralelo. Si tu repo es privado y tenés muchos workflows, calculá. Si te excedés, los siguientes minutos cuestan ~$0.008 USD cada uno.
Vercel Cron es gratuito en el plan Hobby con limitaciones (no podés tener más de 2 crons por proyecto y se ejecuta máximo cada hora). En el plan Pro tenés crons cada minuto. La gran ventaja es que está integrado con tu deploy y no necesitás un repo de GitHub separado para schedule. La desventaja es que sólo funciona si tu app vive en Vercel — si te mudás a otro host, el cron se queda.
Tres capas: una, GitHub Actions te manda un mail cuando un workflow falla (configurable en Settings → Notifications). Dos, el endpoint puede loggear cada invocación en una tabla de bitácora con timestamp + resultado, así podés consultar "qué corrió hoy" desde la base. Tres, un dashboard simple que muestre "última ejecución exitosa: hace X horas" en tu admin panel. Sin la capa 3 te enterás de problemas cuando ya pasaron días.
Tres opciones. Una, dividilo en varios workflows que se disparen secuencialmente (un workflow termina y dispara al siguiente con workflow_call). Dos, mové la lógica pesada a un worker dedicado en otro host (Railway, Fly.io, AWS Lambda) y que el cron solo lo dispare. Tres, replanteá si realmente necesitás procesar todo en un solo run — muchas veces lo que parece "todo de una vez" es en realidad varias cosas independientes que pueden correr en horarios separados.
Es 100% normal que tarde unos minutos extra. GitHub Actions free tier no garantiza ejecución exacta — puede haber delays de 5 a 15 minutos según la carga del cluster. Para tareas críticas con timing estricto (ej. "exactamente a las 9:00 AM"), GitHub Actions no es la herramienta correcta — usá Vercel Cron, AWS EventBridge o un servicio dedicado. Para tareas que toleran un margen de 10-15 minutos sobre el horario nominal (la gran mayoría), está perfecto.
Dos formas. Limpia: poné ambos pasos en el MISMO workflow, uno detrás del otro como steps. Si el A falla, el B no corre. Si el A se reintenta, el B se reintenta también — atómico. Alternativa: workflows separados donde el primero dispara al segundo via la API de GitHub (gh workflow run). Más flexible pero más fácil de romper. Para 95% de los casos, la opción "mismo workflow" gana.
Depende de la escala. Si procesás &lt;100 items por día y cada uno tarda &lt;30s, GitHub Actions + endpoint bastan. Si procesás miles de items por día, si los items son independientes y querés paralelizarlos masivamente, o si necesitás retry sofisticado por item individual, una cola dedicada (BullMQ, SQS, Cloud Tasks) empieza a tener sentido. La regla práctica: empezá con GitHub Actions; cuando notes que tu workflow tarda demasiado o que un fallo de un item rompe todo el run, ahí sí movete a una cola.
Compartir este artículo
Publicado el 5 de junio de 2026
Franco Vassallo
Franco Vassallo
Head of Operations
Seguí leyendo