Linux System Administration

Scheduled Tasks: cron & timers

22 min Lesson 8 of 28

Scheduled Tasks: cron & timers

Every production system runs work on a schedule: log rotation, database vacuuming, certificate renewal, snapshot creation, cache warming, health reports. Two mechanisms exist on modern Linux: the classic cron daemon and systemd timers. A senior DevOps engineer must be fluent in both, know when to choose each, and understand the failure modes that bite teams in production.

cron: The Classic Scheduler

cron has been shipping on Unix systems since 1975. It is still the right tool for simple, user-owned jobs and for systems where systemd is absent. The daemon reads crontab files and runs commands at matching times.

Every crontab line follows a fixed five-field time spec followed by the command:

# ┌──────────── minute (0-59) # │ ┌─────────── hour (0-23) # │ │ ┌──────────── day-of-month (1-31) # │ │ │ ┌─────────── month (1-12 or Jan-Dec) # │ │ │ │ ┌──────────── day-of-week (0-7; 0 and 7 = Sunday) # │ │ │ │ │ # * * * * * command # Run every day at 02:30 UTC 30 2 * * * /usr/local/bin/db-backup.sh # Run every 15 minutes */15 * * * * /usr/local/bin/health-check.sh # Run at 09:00 on weekdays only 0 9 * * 1-5 /usr/local/bin/send-report.sh # Run on the 1st of every month at midnight 0 0 1 * * /usr/local/bin/monthly-cleanup.sh # Special strings (supported by most modern cron implementations) @reboot /usr/local/bin/warm-cache.sh @daily /usr/local/bin/rotate-keys.sh # equivalent to 0 0 * * * @hourly /usr/local/bin/sync-metrics.sh # equivalent to 0 * * * *

Managing Crontabs

Use crontab -e to edit the current user's crontab, crontab -l to list it, and crontab -r to delete it. System-wide jobs for root go in /etc/crontab or as drop-in files under /etc/cron.d/ — these have an extra field for the user to run as:

# /etc/cron.d/myapp — note the USER field after the time spec 30 2 * * * appuser /opt/myapp/bin/backup.sh >> /var/log/myapp/backup.log 2>&1 # List running cron daemon and loaded jobs (systemd-managed distros) systemctl status cron # Debian/Ubuntu systemctl status crond # RHEL/CentOS/Fedora # Check that cron is processing the file (look for permission errors, syntax errors) grep CRON /var/log/syslog # Debian/Ubuntu grep CRON /var/log/cron # RHEL/CentOS
Production pitfall — silent failures: cron captures stdout and stderr and mails them to the local user by default. On servers without a mail transport agent (MTA) configured — which is most cloud VMs — this output is silently discarded. Always redirect output explicitly: >> /var/log/myapp/job.log 2>&1 and ship those logs to your central log aggregator.

systemd Timers: The Modern Alternative

systemd timers are .timer units that activate a paired .service unit. They are more powerful than cron for production workloads because they integrate fully with journald for logging, support dependency ordering, can catch up missed runs (persistence), and emit structured events that monitoring systems can scrape.

A timer always pairs with a .service unit of the same base name. The workflow is: write the service, write the timer, enable the timer.

# /etc/systemd/system/db-backup.service [Unit] Description=Database Backup Job After=network.target postgresql.service [Service] Type=oneshot User=appuser ExecStart=/usr/local/bin/db-backup.sh StandardOutput=journal StandardError=journal # /etc/systemd/system/db-backup.timer [Unit] Description=Run Database Backup Daily at 02:30 UTC [Timer] OnCalendar=*-*-* 02:30:00 AccuracySec=1min Persistent=true [Install] WantedBy=timers.target # Enable and start the timer (NOT the service directly) systemctl daemon-reload systemctl enable --now db-backup.timer # Inspect the timer systemctl list-timers --all systemctl status db-backup.timer journalctl -u db-backup.service --since today
Key idea — Persistent=true: If the system is powered off when a timer was supposed to fire, Persistent=true causes systemd to run the job immediately at next boot if the last run was missed. This is essential for nightly jobs on servers that may be rebooted for maintenance. cron provides no equivalent mechanism.

OnCalendar Syntax

systemd uses its own calendar expression language, which is more expressive than cron's five-field format. Use systemd-analyze calendar to validate any expression before deploying:

# Validate and preview next fire times systemd-analyze calendar "Mon..Fri *-*-* 09:00:00" systemd-analyze calendar "weekly" # Common OnCalendar expressions OnCalendar=daily # midnight every day (00:00:00) OnCalendar=weekly # Monday midnight OnCalendar=hourly # top of every hour OnCalendar=*:0/15 # every 15 minutes OnCalendar=Mon..Fri 09:00:00 # weekdays at 09:00 OnCalendar=*-*-1 00:00:00 # 1st of every month OnCalendar=Sat *-*-* 03:00:00 # Saturdays at 03:00 OnCalendar=2026-07-04 12:00:00 # a specific date and time (one-shot) # Monotonic timers (relative, not calendar-based) OnBootSec=5min # 5 minutes after boot OnUnitActiveSec=1h # 1 hour after last activation (recurring) RandomizedDelaySec=300 # jitter up to 5 min — critical in fleets

Diagram: cron vs systemd Timer Architecture

cron vs systemd timer architecture comparison cron cron daemon crontab / cron.d Shell / Command stdout → mail (often silent) No dependency ordering 5-field syntax No catch-up on missed runs Output often lost systemd Timer .timer unit .service unit Process (isolated) journald (structured logs) After=, Requires= OnCalendar + jitter cgroups isolation Persistent catch-up
cron routes output to local mail (often lost); systemd timers route to journald with full structured logging and catch-up on missed runs.

Production Scheduling Best Practices

Avoid the Thundering Herd

In a fleet of 50 servers, every cron job set to 0 3 * * * fires simultaneously. This creates a traffic spike against shared databases and APIs. For cron, add a random sleep at the start of the script: sleep $((RANDOM % 300)). For systemd timers, use RandomizedDelaySec=300 in the [Timer] block — systemd applies the jitter per host without you touching the script.

Use Locking to Prevent Overlap

If a job runs longer than its interval, a second instance starts while the first is still running. Use flock to prevent this:

#!/usr/bin/env bash # /usr/local/bin/db-backup.sh set -euo pipefail LOCKFILE=/var/lock/db-backup.lock exec 200>"$LOCKFILE" flock -n 200 || { echo "Already running — skipping"; exit 0; } # --- actual work below --- pg_dumpall -U postgres | gzip > /backups/db-$(date +%F).sql.gz find /backups -name "*.sql.gz" -mtime +7 -delete

For systemd services, set Type=oneshot — systemd will not start a second instance while the first is active. Combine with ExecStartPre= checks when needed.

Pro practice — use chronic from moreutils for cron: Install moreutils and prefix cron commands with chronic. It suppresses all output unless the command exits non-zero, so cron mail only arrives when something actually fails — the opposite of the default behaviour where every run produces mail noise. Example: chronic /usr/local/bin/db-backup.sh

When to Use cron vs systemd Timers

Use cron when: you need per-user jobs (crontab -e), the target system has no systemd (containers, Alpine Linux, some embedded systems), or you are maintaining a legacy environment where consistency matters more than features.

Use systemd timers when: the job must depend on a network or database service being up (After=), you need guaranteed catch-up after a reboot (Persistent=true), you want cgroup resource limits on the job process, or you want logs to flow naturally into journald alongside your other service logs. At big-tech scale, systemd timers are the standard for host-level scheduled work because the observability and reliability story is substantially better.

Key idea — containers and Kubernetes: Inside Docker containers, avoid both cron and systemd timers; neither fits the single-process container model well. For containerised scheduled work, use Kubernetes CronJob resources or a distributed scheduler (Temporal, Celery Beat, AWS EventBridge). On bare-metal or VM-based hosts, systemd timers are the right tool.

Debugging Scheduled Jobs

When a job fails silently, this is the diagnostic sequence:

  1. Check that the timer fired: systemctl list-timers --all | grep job-name
  2. Check the service exit code: systemctl status job-name.service
  3. Read the full log: journalctl -u job-name.service -n 100 --no-pager
  4. Run the service manually to reproduce: systemctl start job-name.service
  5. For cron: check /var/log/syslog or /var/log/cron for the daemon's own log lines, then check whether the script uses environment variables that cron does not export (PATH, HOME — always set these explicitly at the top of cron scripts).