09 · Cron Jobs & Scheduled Tasks¶
Backups, cleanup jobs, periodic health checks, certificate renewals — a lot
of server ops work is "run this on a schedule and leave it alone." This
module covers cron, the classic Unix scheduler, and systemd timers, its
modern alternative.
Cron syntax¶
A crontab line has five time fields followed by the command:
# ┌───────────── minute (0-59)
# │ ┌───────────── hour (0-23)
# │ │ ┌───────────── day of month (1-31)
# │ │ │ ┌───────────── month (1-12)
# │ │ │ │ ┌───────────── day of week (0-6, Sunday=0)
# │ │ │ │ │
# * * * * * command-to-run
Common patterns:
0 3 * * * every day at 3:00 AM
*/15 * * * * every 15 minutes
0 * * * * every hour, on the hour
0 0 * * 0 every Sunday at midnight
30 2 1 * * 2:30 AM on the 1st of every month
0 9-17 * * 1-5 every hour from 9am-5pm, Monday-Friday
*/15 means "every 15th value" — for minutes, that's 0,15,30,45. Ranges
(9-17) and lists (1,3,5) both work in any field.
Editing a user's crontab¶
crontab -e # edit the current user's crontab (opens $EDITOR)
crontab -l # list the current user's crontab
crontab -r # remove the current user's entire crontab (careful!)
sudo crontab -u deploy -e # edit another user's crontab
A real crontab entry, with the two habits that save you the most debugging
time later — an absolute PATH and output redirected to a log file:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 3 * * * /opt/myapp/scripts/nightly-backup.sh >> /var/log/myapp-backup.log 2>&1
Cron runs jobs with a minimal environment — no shell profile, no login
PATH. A script that works fine when you run it manually can silently fail
under cron simply because python3, node, or a custom binary isn't found.
Always set PATH explicitly in the crontab, or better, use absolute paths
inside the script itself (/usr/bin/python3 rather than python3).
>> file 2>&1 appends both stdout and stderr to a log file — without this,
cron normally emails output to the user (assuming mail is even configured,
which on a bare cloud VM it usually isn't) and you'd never see failures.
System-wide cron locations¶
Beyond per-user crontabs, system jobs commonly live in:
/etc/crontab # system crontab — has an extra "user" field
/etc/cron.d/* # drop-in files, same format as /etc/crontab
/etc/cron.daily/ # scripts run once a day (via anacron/cron)
/etc/cron.hourly/
/etc/cron.weekly/
/etc/cron.monthly/
/etc/crontab and /etc/cron.d/* entries include a username field between
the schedule and the command (because, unlike a per-user crontab, there's no
implicit "owner"):
# /etc/cron.d/myapp-backup
0 3 * * * deploy /opt/myapp/scripts/nightly-backup.sh >> /var/log/myapp-backup.log 2>&1
systemd timers — the modern alternative¶
A systemd timer pairs with a regular service unit and gives you better
logging (through journald), dependency handling, and the ability to catch up
on missed runs (Persistent=true) if the machine was off at the scheduled
time — none of which plain cron gives you.
# /etc/systemd/system/myapp-backup.service
[Unit]
Description=Nightly backup for myapp
[Service]
Type=oneshot
User=deploy
ExecStart=/opt/myapp/scripts/nightly-backup.sh
# /etc/systemd/system/myapp-backup.timer
[Unit]
Description=Run myapp-backup nightly at 3am
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
Type=oneshot— the service is expected to run once and exit, not stay running (as opposed toType=simplefor long-lived daemons in module 04).Persistent=true— if the machine was powered off (or the timer service wasn't running) at 3:00 AM, run the missed job as soon as it's back up. Plain cron has no equivalent — a missed cron run is just gone.RandomizedDelaySec=300— jitter the actual start time by up to 5 minutes, useful across a fleet so many machines don't all hit a shared resource (like a backup target or database) at the exact same second.
Enable and check it:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp-backup.timer
systemctl list-timers --all | grep myapp-backup
journalctl -u myapp-backup.service # logs from the last run(s)
systemctl list-timers shows you exactly when each timer last ran and when
it's next scheduled — something plain cron gives you no easy way to inspect.
Worked example: a nightly cleanup job, both ways¶
Via cron:
cat <<'EOF' | sudo tee /etc/cron.d/tmp-cleanup > /dev/null
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 4 * * * deploy find /opt/myapp/tmp -type f -mtime +7 -delete >> /var/log/tmp-cleanup.log 2>&1
EOF
sudo chmod 644 /etc/cron.d/tmp-cleanup
Via systemd timer (same job, better observability):
cat <<'EOF' | sudo tee /etc/systemd/system/tmp-cleanup.service > /dev/null
[Unit]
Description=Delete tmp files older than 7 days
[Service]
Type=oneshot
User=deploy
ExecStart=/usr/bin/find /opt/myapp/tmp -type f -mtime +7 -delete
EOF
cat <<'EOF' | sudo tee /etc/systemd/system/tmp-cleanup.timer > /dev/null
[Unit]
Description=Run tmp-cleanup daily at 4am
[Timer]
OnCalendar=*-*-* 04:00:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now tmp-cleanup.timer
How It Actually Works¶
The cron daemon's wake-check loop. cron doesn't set individual timers
per job — it's a single long-running daemon that wakes once a minute,
compares the current minute/hour/day/month/weekday against every parsed
crontab line, and fork/execs any job whose fields match. This is why
cron granularity is fixed at one minute (there's no sub-minute cron syntax)
and why a job that starts a fraction of a second before the wake tick still
waits for the next full minute boundary.
Why cron jobs run with a nearly empty environment. Cron invokes each
job's shell directly from the daemon's own process tree, not from your
interactive login shell — so it never sources ~/.bashrc or ~/.profile,
and PATH defaults to a minimal /usr/bin:/bin. This is the actual
mechanism behind "it works when I run it manually but fails under cron": the
manual run inherits your full interactive-shell environment (aliases,
extended PATH, exported variables), while cron's job inherits only what's
explicitly set in the crontab or the script itself.
systemd timers vs cron. A .timer unit is a systemd unit like any
other, evaluated by the same dependency-and-scheduling engine that starts
services — OnCalendar= expressions are parsed into the same event loop
that handles socket activation and boot ordering, and each timer's
corresponding .service unit gets full journal logging and cgroup isolation
automatically. This is why a failed systemd-timer job shows up in
systemctl status and journalctl -u, while a failed cron job's exit
status vanishes unless you specifically capture and log it yourself.
Exercise¶
- Write a small script that appends a timestamp to
/var/log/heartbeat.log(date -Iseconds >> /var/log/heartbeat.log). - Schedule it via cron to run every 5 minutes (
*/5 * * * *), with an explicitPATHand output redirected. - Wait ~10-15 minutes and confirm multiple timestamps appear in the log.
- Reimplement the same job as a systemd service + timer pair with
OnCalendar=*:0/5(every 5 minutes) andPersistent=true, and confirm viasystemctl list-timersthat it's scheduled correctly and viajournalctl -u <name>.servicethat it's actually running.