# ┌─ minuto (0-59) # │ ┌─ hora (0-23) # │ │ ┌─ día mes (1-31) # │ │ │ ┌─ mes (1-12) # │ │ │ │ ┌─ día semana (0-7, 0 y 7 = domingo) # │ │ │ │ │ # * * * * * comando a ejecutar # Ejemplos rápidos 30 2 * * * /ruta/script.sh # cada día a las 2:30 AM 0 */6 * * * /ruta/script.sh # cada 6 horas 0 9 * * 1 /ruta/script.sh # lunes a las 9 AM 0 0 1 * * /ruta/script.sh # primer día de cada mes */15 * * * * /ruta/script.sh # cada 15 minutos
# * = cualquier valor (wildcard) * * * * * # cada minuto # , = lista de valores 0 9,13,18 * * * # a las 9, 13 y 18 hs 0 0 * * 1,3,5 # lunes, miércoles y viernes a medianoche # - = rango 0 9-17 * * 1-5 # cada hora de 9 a 17, de lunes a viernes # / = cada N (paso) */10 * * * * # cada 10 minutos 0 */4 * * * # cada 4 horas 0 0 */2 * * # cada 2 días a medianoche # @ = atajos predefinidos @reboot # al iniciar el sistema @hourly # cada hora = 0 * * * * @daily # cada día = 0 0 * * * @midnight # medianoche = 0 0 * * * @weekly # cada semana = 0 0 * * 0 @monthly # cada mes = 0 0 1 * * @yearly # cada año = 0 0 1 1 *
# Cada minuto * * * * * # Cada hora en punto 0 * * * * # Cada día a medianoche 0 0 * * * # Cada día a las 3:30 AM 30 3 * * * # Cada 15 minutos */15 * * * * # Cada 6 horas 0 */6 * * * # Lunes a las 8 AM 0 8 * * 1 # Lunes a viernes a las 9 AM 0 9 * * 1-5 # Primer día de cada mes a las 2 AM 0 2 1 * * # Cada 30 minutos entre las 8 y las 18 */30 8-18 * * * # Los domingos a las 4 AM 0 4 * * 0 # Al reiniciar el sistema @reboot # El 1 de enero a medianoche 0 0 1 1 *
Para validar expresiones cron antes de usarlas: crontab.guru — pegar la expresión y ver en lenguaje natural qué hace.
# Editar el crontab del usuario actual crontab -e # Editar el crontab de otro usuario (como root) crontab -u www-data -e # Ver el crontab actual sin editar crontab -l crontab -u www-data -l # Eliminar todos los cron jobs del usuario crontab -r # Exportar crontab a archivo (para backup) crontab -l > ~/crontab_backup_$(date +%Y%m%d).txt # Importar crontab desde archivo crontab ~/crontab_backup.txt # Ver crontabs de todos los usuarios for USER in $(cut -f1 -d: /etc/passwd); do CRON=$(crontab -u $USER -l 2>/dev/null) [ -n "$CRON" ] && echo "=== $USER ===" && echo "$CRON" done
El editor que abre crontab -e depende de la variable EDITOR. Para usar nano: EDITOR=nano crontab -e
# Crontab personal de cada usuario /var/spool/cron/crontabs/root /var/spool/cron/crontabs/www-data /var/spool/cron/crontabs/mi_usuario # Crontab del sistema (tiene columna de usuario) /etc/crontab # Directorios con scripts que cron ejecuta automáticamente /etc/cron.d/ # archivos con formato crontab completo /etc/cron.hourly/ # scripts que corren cada hora /etc/cron.daily/ # scripts que corren cada día /etc/cron.weekly/ # scripts que corren cada semana /etc/cron.monthly/ # scripts que corren cada mes
# Formato de /etc/crontab y archivos en /etc/cron.d/ # (igual que crontab personal pero con columna de usuario) # min hora día mes dsem USUARIO comando 30 2 * * * root /usr/local/bin/backup-diario.sh 0 8 * * 1 www-data /usr/local/bin/limpiar-cache.sh
Para tareas del sistema, preferí crear un archivo en /etc/cron.d/ con un nombre descriptivo en lugar de editar /etc/crontab directamente.
# Ver estado del servicio cron systemctl status cron # Recargar después de cambios manuales en /etc/crontab systemctl reload cron # Reiniciar si hay problemas systemctl restart cron # Habilitar inicio automático systemctl enable cron # Ver logs del demonio cron journalctl -u cron -f journalctl -u cron --since "1 hour ago" grep CRON /var/log/syslog | tail -30
Cron ejecuta los scripts en un entorno muy básico — sin las variables de entorno ni el PATH de tu sesión SSH. Un script que funciona en la terminal puede fallar en cron por este motivo.
# El PATH de cron por defecto es mínimo: # PATH=/usr/bin:/bin # No incluye /usr/local/bin, /usr/sbin, etc. # Variables que cron NO tiene automáticamente: # - HOME (o puede ser distinto al esperado) # - USER # - Variables definidas en .bashrc / .bash_profile # - Variables de entorno de la app (.env) # - nvm, pyenv, rbenv — no se cargan solos
Si un script falla en cron pero funciona en la terminal, el 90% de las veces es un problema de PATH o variables de entorno.
# OPCIÓN 1: Definir PATH al inicio del crontab PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 0 2 * * * /usr/local/bin/mi-script.sh
# OPCIÓN 2: Usar rutas absolutas en el script y en cron 0 2 * * * /usr/bin/php /var/www/mi-app/artisan schedule:run 0 2 * * * /usr/local/bin/node /var/www/mi-app/cron.js
# OPCIÓN 3: Sourcing del entorno al inicio del script #!/bin/bash # Cargar entorno del sistema source /etc/environment source /etc/profile # Cargar variables de la app set -a source /var/www/mi-app/.env set +a # Agregar rutas de herramientas de versión export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && source "$NVM_DIR/nvm.sh" # El resto del script...
# OPCIÓN 4: Usar bash -l (login shell) para cargar el entorno completo 0 2 * * * bash -l -c '/usr/local/bin/mi-script.sh'
La opción más robusta es siempre usar rutas absolutas tanto en el crontab como dentro de los scripts.
# Definir variables al inicio del crontab (aplican a todos los jobs) SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin MAILTO=tu@email.com # email para recibir output (vacío = silencio) HOME=/root # Deshabilitar emails de output (para jobs silenciosos) MAILTO="" # Job que sí envía email solo si hay error 0 2 * * * /usr/local/bin/backup.sh 2>&1 | mail -s "Backup" tu@email.com # Silenciar completamente el output de un job * * * * * /usr/local/bin/check.sh > /dev/null 2>&1 # Guardar output en log y también enviarlo 0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
Por defecto, cron envía el output de cada job por email al usuario. Con MAILTO="" al inicio del crontab lo silenciás globalmente.
# Ver logs de cron en tiempo real journalctl -u cron -f # Ver todas las ejecuciones de hoy journalctl -u cron --since today # Buscar en syslog (método clásico) grep CRON /var/log/syslog grep CRON /var/log/syslog | tail -50 grep CRON /var/log/syslog | grep "mi-script" # Ver si un job específico corrió grep "backup-diario" /var/log/syslog # Output de cron en syslog se ve así: # May 28 02:00:01 vps CRON[12345]: (root) CMD (/usr/local/bin/backup-diario.sh)
Cron solo registra que ejecutó el comando, no su output. Para ver si el script funcionó correctamente, redirigí su output a un archivo de log.
# Redirigir todo el output al log del sistema 0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1 # Con timestamp en el log 0 2 * * * /usr/local/bin/backup.sh 2>&1 \ | while IFS= read -r line; \ do echo "[$(date '+%Y-%m-%d %H:%M:%S')] $line"; \ done >> /var/log/backup.log # Patrón dentro del script para logs con timestamp #!/bin/bash LOG="/var/log/mi-script.log" log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$LOG"; } log "=== Inicio de ejecución ===" log "Procesando archivos..." log "=== Fin OK ==="
# Rotación del log para que no crezca sin límite # /etc/logrotate.d/mi-script /var/log/mi-script.log { daily rotate 14 compress missingok notifempty create 640 root adm }
# PASO 1: Verificar que cron está corriendo systemctl status cron # PASO 2: Probar el script manualmente como root bash /usr/local/bin/mi-script.sh # PASO 3: Simular el entorno de cron env -i HOME=/root PATH=/usr/bin:/bin \ bash /usr/local/bin/mi-script.sh # PASO 4: Verificar que el script tiene permisos de ejecución ls -la /usr/local/bin/mi-script.sh chmod +x /usr/local/bin/mi-script.sh # PASO 5: Verificar que la primera línea es correcta head -1 /usr/local/bin/mi-script.sh # Debe ser: #!/bin/bash o #!/bin/sh # PASO 6: Buscar errores en syslog después de la ejecución grep CRON /var/log/syslog | tail -20 # PASO 7: Capturar el output del job temporalmente * * * * * /usr/local/bin/mi-script.sh > /tmp/cron-debug.log 2>&1 # Esperar 1 minuto y revisar: cat /tmp/cron-debug.log # PASO 8: Verificar la sintaxis del crontab crontab -l # Debe terminar con una línea en blanco al final
Una causa muy frecuente de fallo silencioso: el crontab no termina en una línea en blanco. Cron ignora la última línea si no tiene newline al final.
Si un job tarda más que su intervalo de ejecución, cron lanza otra instancia mientras la anterior sigue corriendo. Esto puede causar conflictos en scripts de backup, deploy, etc.
# Método 1: flock — bloqueo a nivel de archivo */5 * * * * flock -n /tmp/mi-script.lock \ /usr/local/bin/mi-script.sh # Método 2: con timeout (máximo 4 minutos de ejecución) */5 * * * * flock -n -w 0 /tmp/mi-script.lock \ timeout 240 /usr/local/bin/mi-script.sh # Método 3: dentro del propio script con PID file #!/bin/bash LOCKFILE="/tmp/mi-script.pid" if [ -f "$LOCKFILE" ]; then PID=$(cat "$LOCKFILE") if kill -0 "$PID" 2>/dev/null; then echo "Ya está corriendo (PID $PID), saliendo" exit 0 fi fi echo $$ > "$LOCKFILE" trap "rm -f $LOCKFILE" EXIT # El resto del script...
CRON — preferir cuando:
✓ Tareas simples de una línea
✓ El equipo ya está familiarizado con crontab
✓ Compatibilidad con sistemas sin systemd
✓ Instalación y configuración rápida
SYSTEMD TIMERS — preferir cuando:
✓ Necesitás logs integrados (journalctl)
✓ Dependencias entre servicios (After=mariadb.service)
✓ Recuperar ejecuciones perdidas (Persistent=true)
✓ Timeout automático de la tarea
✓ Control de reintentos en caso de fallo
✓ Monitorear el estado con systemctl
Un timer requiere dos archivos: el service (qué ejecutar) y el timer (cuándo ejecutarlo).
# 1. Crear el archivo de servicio # /etc/systemd/system/limpiar-cache.service [Unit] Description=Limpieza de caché de la app After=network.target [Service] Type=oneshot User=www-data WorkingDirectory=/var/www/mi-app ExecStart=/usr/bin/php artisan cache:clear StandardOutput=journal StandardError=journal
# 2. Crear el archivo de timer # /etc/systemd/system/limpiar-cache.timer [Unit] Description=Timer para limpieza de caché Requires=limpiar-cache.service [Timer] OnCalendar=*-*-* 03:00:00 # diario a las 3 AM Persistent=true RandomizedDelaySec=2min [Install] WantedBy=timers.target
# 3. Activar systemctl daemon-reload systemctl enable limpiar-cache.timer systemctl start limpiar-cache.timer # 4. Verificar systemctl list-timers limpiar-cache.timer systemctl status limpiar-cache.timer # 5. Ver logs de la última ejecución journalctl -u limpiar-cache.service -n 30 # 6. Ejecutar manualmente para probar systemctl start limpiar-cache.service
# Formato: DíaSemana Año-Mes-Día Hora:Minuto:Segundo # Diario a las 2 AM OnCalendar=*-*-* 02:00:00 # Cada hora OnCalendar=hourly # Cada lunes a las 9 AM OnCalendar=Mon *-*-* 09:00:00 # Primer día de cada mes OnCalendar=*-*-01 00:00:00 # Cada 15 minutos OnCalendar=*:0/15 # Lunes a viernes a las 8 AM OnCalendar=Mon..Fri *-*-* 08:00:00 # Verificar expresión antes de usarla systemd-analyze calendar "Mon *-*-* 09:00:00" # Ver todos los timers activos y próxima ejecución systemctl list-timers --all
# /etc/cron.d/vps-tasks # Tareas automáticas del VPS SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin MAILTO="" # ── BACKUPS ───────────────────────────────────────────────── # Backup completo: bases de datos + archivos + configs (2 AM) 0 2 * * * root /usr/local/bin/backup-diario.sh >> /var/log/backup.log 2>&1 # Verificar que el backup se hizo correctamente (8:30 AM) 30 8 * * * root /usr/local/bin/verificar-backups.sh # ── MANTENIMIENTO ─────────────────────────────────────────── # Renovar certificados SSL (Let's Encrypt) 0 3 * * * root certbot renew --quiet --post-hook "systemctl reload nginx" # Rotar logs de Nginx 0 0 * * * root /usr/sbin/logrotate /etc/logrotate.conf # Actualizar base de datos de fail2ban @daily root fail2ban-client reload # Limpiar sesiones PHP viejas */30 * * * * root find /tmp -name "sess_*" -mmin +60 -delete 2>/dev/null # ── LIMPIEZA ──────────────────────────────────────────────── # Eliminar logs viejos (más de 30 días) 0 4 * * 0 root find /var/log/nginx -name "*.gz" -mtime +30 -delete # Limpiar imágenes Docker sin usar (domingos a las 4 AM) 0 4 * * 0 root docker system prune -f >> /var/log/docker-prune.log 2>&1 # ── MONITOREO ─────────────────────────────────────────────── # Verificar SSL expiry mensualmente 0 9 1 * * root /usr/local/bin/check-ssl-expiry.sh # Reporte semanal de uso de disco (lunes 8 AM) 0 8 * * 1 root df -h | mail -s "Uso de disco VPS" tu@email.com
Laravel tiene su propio scheduler interno. Solo necesitás un único job en cron que llame al scheduler cada minuto — Laravel se encarga del resto.
# Un solo job en crontab para toda la app Laravel * * * * * www-data cd /var/www/mi-laravel && php artisan schedule:run >> /dev/null 2>&1 # Con ruta absoluta (más robusto en cron) * * * * * www-data /usr/bin/php /var/www/mi-laravel/artisan schedule:run >> /dev/null 2>&1 # En app/Console/Kernel.php se definen las tareas: # $schedule->command('backup:run')->daily()->at('02:00'); # $schedule->command('cache:clear')->hourly(); # $schedule->job(new ProcessEmails)->everyFiveMinutes(); # Ver las tareas programadas definidas en Laravel php artisan schedule:list # Ejecutar el scheduler manualmente php artisan schedule:run # Correr en modo verbose (ver qué está ejecutando) php artisan schedule:run -v
# Opción A: usar cron del sistema para Node (más simple) 0 * * * * www-data /usr/bin/node /var/www/mi-app/cron/hourly.js >> /var/log/node-cron.log 2>&1 # Opción B: node-cron dentro de la app (programático) # npm install node-cron
// cron/tasks.js const cron = require('node-cron'); // Cada día a las 2 AM cron.schedule('0 2 * * *', async () => { console.log('Ejecutando backup...'); await runBackup(); }, { timezone: 'America/Argentina/Buenos_Aires' }); // Cada 15 minutos cron.schedule('*/15 * * * *', () => { processQueue(); });
Si la app Node corre con PM2, los cron internos se reinician solos si la app cae. Si usás cron del sistema, asegurate de que el script Node existe y tiene permisos correctos.
# Opción A: cron del sistema ejecuta script Python 0 6 * * * www-data /var/www/mi-app/venv/bin/python /var/www/mi-app/tasks/daily.py >> /var/log/python-tasks.log 2>&1 # Importante: usar el Python del virtualenv, no el del sistema # ✓ /var/www/mi-app/venv/bin/python # ✗ python3 (puede ser otro venv o versión diferente) # Opción B: APScheduler dentro de la app Django/Flask # pip install apscheduler
# tasks/scheduler.py from apscheduler.schedulers.background import BackgroundScheduler scheduler = BackgroundScheduler(timezone="America/Argentina/Buenos_Aires") @scheduler.scheduled_job('cron', hour=2, minute=0) def nightly_backup(): print("Ejecutando backup...") @scheduler.scheduled_job('interval', minutes=15) def process_queue(): print("Procesando cola...") scheduler.start()
# 1. Limitar quién puede usar cron # /etc/cron.allow — solo los usuarios listados pueden usar cron echo "root" > /etc/cron.allow echo "deploy" >> /etc/cron.allow # 2. Nunca poner contraseñas directamente en el crontab # MAL: 0 2 * * * mysqldump -u root -pMiContraseña mi_base > backup.sql # BIEN: leer de archivo con permisos restrictivos 0 2 * * * /usr/local/bin/backup.sh # el script lee /etc/mysql/backup.pass # 3. Ejecutar con el usuario mínimo necesario (no siempre root) 0 2 * * * www-data /var/www/mi-app/scripts/limpiar.sh # 4. Verificar permisos de los scripts chmod 750 /usr/local/bin/backup.sh # solo root puede ejecutar chown root:root /usr/local/bin/backup.sh # 5. Agregar set -euo pipefail al inicio de los scripts # para que fallen ruidosamente ante errores #!/bin/bash set -euo pipefail