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.
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 + secret | cPanel UI o crontab -e | Crear cuenta + verificar |
Costo | Free hasta cierto límite | Free 2.000 min/mes | Gratis si ya pagás hosting | $5-30/mes según uso |
Funciona en cualquier host | Solo Vercel | Sí | Si tu host lo soporta | Sí |
Logs accesibles | Dashboard de Vercel | GitHub Actions UI | Variable según host | Dashboard propio |
Retry automático | No | Configurable | No | Sí |
Visible para todo el equipo | Si tienen acceso a Vercel | Si tienen acceso al repo | Solo quien tiene SSH | Cuenta separada |
Trigger manual | Sí, desde dashboard | Sí, workflow_dispatch | Hay que SSH | Sí, 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:
# ❌ 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:
# ✅ 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-minutesgeneroso para no quedar colgado eternamente si algo se descalibra. - Cada respuesta incluye
remaining. Cuando llega a 0, el workflow corta y reporta totales.
# 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
doneEl 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.
# 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"
doneOtros 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 resultado | Re-procesamiento, costos disparados |
El batch cabe en el timeout del host | ~50% del timeout máximo | 504 silenciosos, progresos perdidos |
workflow_dispatch está activado | Botón "Run workflow" disponible | No podés debuggear hasta el próximo schedule |
Hay un cap de iteraciones | for i in seq 1 30 | Loop infinito si la query nunca llega a 0 |
El error de un batch tira el workflow | set -e + exit 1 en HTTP != 200 | Workflow "verde" mientras todo falla |
Logs accesibles para todo el equipo | GitHub Actions UI o equivalente | Solo 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.
