Scheduling Tasks with cron and systemd Timers

Advanced
13 min

Scheduling Tasks with cron and systemd Timers

Backups, log rotation, certificate renewal, report generation and cache warming all need to run without anyone typing a command. Linux offers two schedulers: cron, which has worked the same way for decades and is what most documentation assumes, and systemd timers, which integrate with services, logging and dependency handling. This lesson teaches the crontab format precisely, the environment quirks that make cron jobs fail silently, and how to write the equivalent timer.

Crontab Format

Each user has a crontab, edited with crontab -e. Every line is five time fields followed by a command.

bash
# ┌───────── minute (0-59) # │ ┌─────── hour (0-23) # │ │ ┌───── day of month (1-31) # │ │ │ ┌─── month (1-12) # │ │ │ │ ┌─ day of week (0-7, 0 and 7 are Sunday) # │ │ │ │ │ # * * * * * command

| Expression | Runs | |---|---| | * * * * * | every minute | | */5 * * * * | every 5 minutes | | 0 * * * * | at the top of every hour | | 30 2 * * * | daily at 02:30 | | 0 9 * * 1-5 | 09:00 on weekdays | | 0 0 1 * * | midnight on the first of each month | | 0 3 * * 0 | 03:00 every Sunday | | 0 6,18 * * * | 06:00 and 18:00 | | @reboot | once at boot | | @daily, @hourly, @weekly | shortcuts for 0 0 * * * etc. |

Day-of-month and day-of-week are ORed when both are restricted: 0 0 13 * 5 runs on the 13th and on every Friday, not only on Friday the 13th.

Managing Crontabs

bash
crontab -e # edit your crontab (opens $EDITOR) crontab -l # list it crontab -r # remove it entirely (no confirmation) sudo crontab -u www-data -e # edit another user's crontab ls /etc/cron.d/ # system jobs installed by packages ls /etc/cron.daily/ # scripts run once a day by the system

/etc/crontab and files in /etc/cron.d/ have an extra field — the user to run as — between the schedule and the command. Packages such as logrotate and certbot install their jobs there.

Why Cron Jobs Fail Silently

Cron runs commands with a minimal environment: no ~/.bashrc, a PATH of just /usr/bin:/bin, and /bin/sh rather than bash. A script that works in your terminal can fail in cron because node, aws or psql are not on that PATH. The fixes:

bash
# At the top of the crontab SHELL=/bin/bash PATH=/usr/local/bin:/usr/bin:/bin MAILTO=ops@example.com # Always use absolute paths and capture output 30 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

Output that is not redirected is emailed to MAILTO (or the user) if a mail system exists, and otherwise discarded. Redirect to a log file so failures leave evidence. Also note that % is special in crontab commands (it means newline); escape it as \% in date +\%F.

Check whether a job ran at all in the system log:

bash
grep CRON /var/log/syslog | tail # Debian/Ubuntu journalctl -u cron --since today # systemd systems

Preventing Overlap

A job scheduled every five minutes that sometimes takes six will pile up. Wrap it in flock so a second copy exits if the first still holds the lock.

bash
*/5 * * * * flock -n /tmp/sync.lock /usr/local/bin/sync.sh >> /var/log/sync.log 2>&1

systemd Timers

A timer is a pair of unit files: a .service that describes the work and a .timer that describes when. The service is an ordinary one-shot service, so it gets logging in journalctl, resource limits, and can depend on the network being up.

bash
# /etc/systemd/system/backup.service [Unit] Description=Nightly database backup After=network-online.target [Service] Type=oneshot User=backup ExecStart=/usr/local/bin/backup.sh
bash
# /etc/systemd/system/backup.timer [Unit] Description=Run backup nightly at 02:30 [Timer] OnCalendar=*-*-* 02:30:00 Persistent=true RandomizedDelaySec=300 [Install] WantedBy=timers.target

Persistent=true runs the job at next boot if the machine was off at the scheduled time, something cron cannot do. RandomizedDelaySec spreads load when many machines share a schedule.

bash
sudo systemctl daemon-reload sudo systemctl enable --now backup.timer systemctl list-timers # next and last run of every timer systemctl status backup.timer sudo systemctl start backup.service # run the job right now, manually journalctl -u backup.service --since yesterday

OnCalendar Syntax

The format is DayOfWeek Year-Month-Day Hour:Minute:Second, with * as a wildcard and / for steps. Check any expression with systemd-analyze calendar.

| OnCalendar | Meaning | |---|---| | hourly, daily, weekly | shortcuts | | *-*-* 02:30:00 | every day at 02:30 | | Mon..Fri *-*-* 09:00:00 | weekdays at 09:00 | | *:0/15 | every 15 minutes | | *-*-01 00:00:00 | first of the month | | Sun *-*-* 03:00:00 | Sundays at 03:00 |

bash
systemd-analyze calendar "Mon..Fri *-*-* 09:00:00"

For "every 10 minutes after boot regardless of clock time", use OnBootSec=5min and OnUnitActiveSec=10min instead of OnCalendar.

Common Mistakes

  • Relative paths or commands that rely on PATH set in .bashrc.
  • Forgetting 2>&1, so errors vanish.
  • Unescaped % in the command.
  • Editing /etc/crontab and omitting the user field.
  • Enabling the .service instead of the .timer, which runs it once at boot and never again.
Quick Quiz
Question 1 of 3

Which crontab line runs a job at 07:15 every Monday?

Key Takeaways

  • Crontab lines are minute hour day month weekday command; */n means every n units and @daily style shortcuts exist.
  • Cron jobs run with a minimal environment: set PATH, use absolute paths, redirect output with >> log 2>&1, escape %.
  • flock -n prevents overlapping runs; MAILTO or a log file makes failures visible.
  • A systemd timer is a .timer plus a .service; enable the timer, inspect with systemctl list-timers and journalctl -u.
  • OnCalendar with Persistent=true catches up on missed runs; verify expressions with systemd-analyze calendar.

Next lesson: systemd Services and journalctl — run your own applications as managed services and read their logs.

Scheduling Tasks with cron and systemd Timers - Linux & Command Line | CodeYourCraft | CodeYourCraft