← Blog

Reemplaza Heroku Scheduler por tareas cron en tu servidor

Railyard

18 de junio de 2026 · 11 min de lectura

Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta

La propia documentación de Heroku para Scheduler lo dice sin rodeos: “la ejecución de las tareas de Scheduler se espera, pero no está garantizada”. La misma página señala que, en casos muy raros, una tarea puede saltarse o ejecutarse dos veces, y que las únicas frecuencias disponibles son cada 10 minutos, cada hora y una vez al día. Para cualquier cosa que notarías si faltara, un reporte nocturno, una verificación de renovación de suscripción, una tarea de limpieza que evita que una tabla crezca sin control, eso no es una base sólida. La propia documentación de Heroku recomienda un proceso clock personalizado para cualquier cosa crítica.

Esta guía reemplaza Scheduler por cron simple en un servidor que tú controlas: cómo escribir la tarea, cómo evitar que dos ejecuciones se superpongan, cómo configurar correctamente la zona horaria, y a dónde van realmente los registros.

Lo que necesitarás

  • Un servidor donde tu app ya esté corriendo, o uno nuevo si estás consolidando. Un Hetzner CX23 (2 vCPU, 4 GB de RAM, 40 GB NVMe, 5,99 €/mes sin impuestos) alcanza para una app web más sus tareas programadas.
  • Acceso SSH con un usuario que pueda editar su propio crontab, o root si la tarea necesita ejecutarse como una cuenta de servicio.
  • Una lista de lo que Scheduler está ejecutando actualmente. Abre el panel de Scheduler con heroku addons:open scheduler -a your-app-name y anota el comando, la frecuencia y la próxima hora de ejecución de cada tarea antes de empezar.

Paso 1: escribe la tarea como un comando o script simple

Sea lo que sea que Scheduler ejecute actualmente, ya sea rails runner SomeJob.perform, python manage.py cleanup, o un script SQL en bruto, debe ser algo que puedas invocar como un único comando de shell, sin que el entorno del dyno de Heroku haga nada implícito por ti. Envuélvelo en un pequeño script si ocupa más de una línea, para que cron tenga una sola cosa que llamar y tú tengas un solo lugar donde agregar registro:

#!/usr/bin/env bash
set -euo pipefail

cd /opt/myapp
set -a
source /opt/myapp/.env
set +a

bundle exec rails runner "NightlyReportJob.perform_now" >> /var/log/myapp/nightly-report.log 2>&1

Hazlo ejecutable:

chmod +x /opt/myapp/bin/nightly-report.sh

set -euo pipefail importa aquí más que en la mayoría de los scripts: cron no reintentará una tarea fallida por ti, y sin esta línea, un paso que falla a mitad de camino puede dejar que el resto del script siga corriendo en silencio e informe éxito igual. set -a antes de source exporta cada variable que el archivo define, para que el comando que ejecutas realmente las vea; un source simple solo las definiría dentro del propio shell del script.

Ejecútalo una vez a mano antes de programarlo:

/opt/myapp/bin/nightly-report.sh
tail -n 20 /var/log/myapp/nightly-report.log

Deberías ver la salida normal de la tarea en el registro, y echo $? justo después del script debería imprimir 0. Crea primero /var/log/myapp (sudo mkdir -p /var/log/myapp && sudo chown $USER /var/log/myapp) si no existe.

Paso 2: carga las variables de entorno explícitamente

Este es el paso que atrapa a casi todos los que dejan Heroku. Las tareas de Scheduler se ejecutan dentro de un dyno de un solo uso que hereda automáticamente todo tu conjunto de config vars. Cron se ejecuta con un entorno mínimo, por lo general solo PATH, HOME, SHELL y lo que la propia crontab defina, nada del .env de tu app ni del perfil de inicio de sesión de tu shell.

Carga tu archivo de entorno explícitamente dentro del script, como se mostró arriba, o define las variables al inicio de la propia crontab, una pareja NAME=value por línea:

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
DATABASE_URL=postgres://user:pass@localhost:5432/myapp_production

Cualquiera de las dos funciona. El enfoque con script es más fácil de mantener en control de versiones junto con tu app; el enfoque con crontab mantiene los secretos fuera de un archivo que se despliega junto con tu código. Elige uno y no dependas de que cron recoja algo de un shell de inicio de sesión, porque no lo hará.

Paso 3: configura la zona horaria correctamente

La documentación de Heroku señala que la próxima hora de ejecución de las tareas diarias de Scheduler está en UTC, y te pide que sumes tú mismo el desfase de UTC si quieres una tarea a cierta hora local. Si lo hiciste así, tu horario actual ya está escrito en UTC. El cron del sistema, en cambio, se ejecuta en la zona horaria que tenga configurada el propio servidor, que suele ser UTC en un servidor cloud nuevo, pero vale la pena verificarlo.

Verifica qué hora cree el servidor que es:

timedatectl

Deberías ver una línea Time zone:. Si dice Etc/UTC (UTC, +0000), puedes copiar tus horarios de Scheduler sin cambios: una tarea diaria a las 03:00 en Heroku se convierte en 0 3 * * *.

Si prefieres escribir los horarios en hora local, cambia la zona horaria del servidor y reinicia cron para que tome el cambio:

sudo timedatectl set-timezone America/Toronto
sudo systemctl restart cron

Ten en cuenta que esto cambia el reloj para todo en el servidor, incluyendo las marcas de tiempo de los registros, y que la hora local tiene cambios de horario de verano: una tarea programada entre las 02:00 y las 03:00 puede saltarse o ejecutarse dos veces las noches del cambio de hora. Quedarse en UTC evita ambos problemas, por eso muchos equipos mantienen sus servidores en UTC y hacen la conversión mentalmente.

Paso 4: agrega la entrada de crontab, con bloqueo

Instala la crontab para el usuario que debe ser dueño de la tarea:

crontab -e
*/10 * * * * /usr/bin/flock -n /tmp/myapp-sync.lock /opt/myapp/bin/sync-job.sh
0 3 * * * /usr/bin/flock -n /tmp/myapp-nightly-report.lock /opt/myapp/bin/nightly-report.sh

flock -n es la respuesta directa al problema de Scheduler de que “las ejecuciones pueden superponerse”, solo que aquí lo controlas tú en vez de esperar que no pase. Toma un bloqueo exclusivo sobre el archivo indicado antes de ejecutar el comando, y -n significa que se rinde de inmediato en vez de encolar una segunda ejecución, si una ejecución anterior de la misma tarea sigue reteniendo el bloqueo. Una tarea de sincronización de diez minutos que a veces tarda doce simplemente se saltará esa ejecución superpuesta en vez de apilar dos copias de sí misma contra las mismas filas de la base de datos.

Confirma que la crontab esté instalada:

crontab -l

Deberías ver ambas líneas listadas de vuelta exactamente como las escribiste.

Paso 5: envía los registros a un lugar donde realmente los revises

El comportamiento predeterminado de cron, enviar la salida de la tarea por correo al usuario local vía sendmail, no sirve de nada en un servidor sin un agente de transporte de correo configurado, que es la mayoría. Sin redirección, la salida simplemente desaparece.

El script del paso 1 ya redirige a un archivo de registro, que es la solución más simple. Para cualquier cosa que quieras agregar en vez de tener dispersa en archivos de registro por tarea, pásala por logger para enviarla al diario del sistema:

0 3 * * * /usr/bin/flock -n /tmp/myapp-nightly-report.lock /opt/myapp/bin/nightly-report.sh 2>&1 | logger -t myapp-nightly-report

Luego síguelo con:

journalctl -t myapp-nightly-report -n 50

Deberías ver la salida de la tarea con la marca de tiempo esperada según el horario, sin nada mezclado de otras tareas.

Paso 6: si tu app corre en Docker Compose

Si tu app está desplegada con Docker Compose en vez de directamente en el host, mantén cron en el host y haz que cada entrada arranque un contenedor de corta duración a partir del propio servicio de la app:

0 3 * * * /usr/bin/flock -n /tmp/myapp-nightly-report.lock /usr/bin/docker compose -f /opt/myapp/compose.yaml run --rm -T web bin/rails runner "NightlyReportJob.perform_now" >> /var/log/myapp/nightly-report.log 2>&1

docker compose run arranca un contenedor nuevo a partir de la imagen del servicio web, con el mismo env_file y entorno que el servicio define, ejecuta el único comando, y --rm elimina el contenedor después. -T desactiva la pseudo-terminal, que cron no tiene. Como la tarea corre en la misma imagen que el proceso web, siempre usa el código de tu último despliegue.

Verifica primero la ruta con which docker; /usr/bin/docker es donde los propios paquetes de Docker lo instalan en Ubuntu y Debian.

Hacer correr crond dentro de su propio contenedor es más difícil de lo que parece: el demonio cron arranca las tareas con un entorno limpio, así que las variables que Compose pasa al contenedor no llegan a las tareas, y el binario crond y la ubicación de su crontab difieren entre las imágenes basadas en Alpine y las basadas en Debian. El cron del host más docker compose run evita ambos problemas.

El atajo: hazlo con Railyard

La función Cron de Railyard se encarga del script, el entorno y los registros por ti. Agrega una entrada cron con una expresión de horario como 0 3 * * * y un comando, y Railyard la ejecuta en un contenedor de un solo uso a partir de la propia imagen de tu app, con las variables de entorno de la app ya adjuntas, así que no hay crontab que editar en el servidor ni .env que cargar. Los horarios se evalúan en UTC, la misma convención que las tareas diarias de Heroku Scheduler, así que las horas que escribiste para Heroku se mantienen sin cambios. Cada ejecución aparece en el historial de ejecución con su código de salida y su salida, así que “¿realmente se ejecutó la tarea de anoche?” se convierte en una página que revisas en vez de un archivo de registro que debes recordar ir a buscar.

Una cosa que Railyard no hace es saltarse una ejecución mientras la anterior sigue en curso. Si una tarea puede tardar más que su intervalo, hazla segura para ejecutarse dos veces, o dale un intervalo más largo.

Está junto a la pestaña Workers, que cumple el rol equivalente para procesos de fondo de larga duración como Sidekiq o Celery en vez de tareas programadas; mira Workers y tareas programadas. Ambos usan la misma imagen que corre tu proceso web, así que no hay nada extra que construir ni mantener sincronizado.

Solución de problemas

La tarea se ejecuta a la hora incorrecta. Casi siempre es un desajuste de zona horaria entre lo que asumías que tenía configurado el servidor y lo que timedatectl realmente reporta, sobre todo justo después de dejar el Scheduler de Heroku, que solo trabaja en UTC. Verifica primero la zona horaria del servidor, y luego convierte el horario a la zona horaria del servidor o cambia la zona horaria del servidor como se muestra en el paso 3.

La tarea parece ejecutarse dos veces, o interfiere consigo misma. Es el problema de superposición “raro, pero pasa” de Scheduler, apareciendo ahora en tu propia infraestructura, generalmente porque una tarea a veces tarda más que su intervalo. Envuelve el comando en flock -n como se muestra en el paso 4; una ejecución superpuesta saltada casi siempre es más segura que dos copias corriendo a la vez sobre los mismos datos.

No aparece nada en ningún registro, y no puedes saber si la tarea se ejecutó siquiera. La salida predeterminada de cron basada en correo no va a ningún lado en un servidor sin un MTA configurado. Redirige la salida explícitamente a un archivo o a logger, y verifica que tu redirección realmente funcione ejecutando a mano, una vez, el comando exacto que cron ejecutaría.

El script funciona cuando lo ejecutas a mano, pero falla bajo cron. Esto es casi siempre el entorno faltante del paso 2. Ejecútalo con un entorno reducido para reproducir el problema localmente: env -i /bin/bash -c '/opt/myapp/bin/nightly-report.sh' fallará de la misma forma que el entorno mínimo de cron, lo que hace mucho más rápido encontrar la variable faltante que depurar directamente en el servidor.

Permiso denegado al ejecutar el script. Confirma que sea ejecutable (chmod +x) y que el usuario en cuya crontab está realmente sea dueño, o pueda leer, cada archivo y directorio que el script toca, incluyendo el directorio padre del archivo de registro si ese directorio aún no existe.

Preguntas frecuentes

¿Puede cron ejecutar tareas con más frecuencia que el mínimo de diez minutos de Heroku Scheduler? Sí. La sintaxis estándar de cron solo baja hasta una vez por minuto, pero puedes ir más allá haciendo que una entrada que se ejecuta cada minuto lance un script corto que haga un bucle con un sleep, si de verdad necesitas una programación por debajo del minuto, algo raro para cualquier cosa que antes corría en Scheduler.

¿Cuál es el equivalente de heroku run para una tarea puntual? Conéctate por SSH y ejecuta el comando directamente; no hay un dyno que levantar primero. Si la tarea necesita exactamente el mismo entorno que tu app en ejecución, ejecútala dentro del propio contenedor de la app con docker compose exec web <command> en vez de recurrir a herramientas a nivel del host que podrían ver un entorno distinto.

¿Cron sobrevive a un reinicio del servidor? Sí, cron o crond es un servicio del sistema que arranca al iniciar en todas las distribuciones Linux comunes, y las crontabs se guardan en disco, no en memoria. Confirma que esté habilitado con systemctl is-enabled cron (o crond, según la distribución) si quieres estar seguro después de configurar un servidor nuevo.

¿Cómo manejo los secretos que necesita una tarea programada, sin ponerlos en texto plano en el archivo crontab? Guárdalos en el archivo .env que carga el script, con permisos de archivo restrictivos (chmod 600). La crontab de cada usuario ya es privada para ese usuario, pero mantener los secretos en un solo archivo .env significa que la app y cada tarea programada leen los mismos valores, y rotar un secreto es una sola edición en vez de varias.

¿Qué pasa si el servidor se reinicia en medio de una tarea? Lo mismo que pasa si el proceso se mata de cualquier otra forma: lo que la tarea estaba haciendo se detiene, inconcluso. Las tareas idempotentes (seguras para ejecutarse dos veces, o capaces de retomar donde quedaron) toleran esto bien; las que no lo son necesitan volverse idempotentes sin importar qué las programe, ya que el mismo modo de falla existe también bajo Scheduler.

Dejar Scheduler consiste sobre todo en hacer explícito lo que Heroku hacía implícitamente por ti: el entorno, la zona horaria, y qué pasa si dos ejecuciones se superponen. Una vez resueltos esos tres puntos, cron resulta más simple de entender que cualquier planificador alojado, porque puedes leer exactamente qué hará en un archivo de texto en vez de confiar en un panel.

Por qué eligen Railyard para esto

  • Tu propia cuenta en la nube. Despliega en DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, o cualquier servidor Linux por SSH, facturado directamente por tu proveedor, sin márgenes adicionales.
  • Un precio fijo por equipo. El plan Free es gratis para siempre: un servidor más un servidor de base de datos, tres apps, sin tarjeta. Pro cuesta $19 al mes por hasta cinco servidores y apps ilimitadas. Team cuesta $49 al mes con servidores ilimitados. Sin cargos por app, por despliegue, por asiento ni por gigabyte.
  • Plantillas de un clic. Despliega apps de código abierto, como bases de datos, analítica y herramientas de desarrollo, en tu propio servidor con un clic.
  • Bases de datos administradas. Postgres, MySQL y Redis, con copias de seguridad programadas y verificadas por restauración, y recuperación a un punto en el tiempo.
  • Despliegue con git push. Despliega desde GitHub, GitLab o la CLI, con HTTPS automático y lanzamientos sin tiempo de inactividad.

Programa tareas desde la página de la app

El Cron de Railyard ejecuta un comando en la propia imagen de tu app según un horario, con historial de ejecución para cada corrida.

Plan gratis, sin tarjeta, y 14 días de Pro para cada equipo nuevo. Un precio fijo por equipo.