Scheduling work: cron and systemd timers

Crontab syntax and its environment traps, then systemd timers with real logging and catch-up behaviour.

Crontab syntax

FieldRangeNotes
Minute0-59*/5 every five minutes
Hour0-2324-hour clock, server time zone
Day of month1-311,15 for two days
Month1-12or jan, feb
Day of week0-70 and 7 are Sunday; mon-fri is clearer
CommandrestRun through /bin/sh, one line unless you use % escapes
crontab -e                # edit your own crontab
crontab -l                # list it
sudo crontab -u deploy -l # someone else's
sudo crontab -u deploy -e
# /etc/cron.d/nightly-backup (root, note the user column)
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
[email protected]
30 2 * * *  deploy  /opt/app/backup.sh >> /var/log/backup.log 2>&1

# in a personal crontab there is no user column
15 3 * * 0  /usr/bin/find /srv/tmp -type f -mtime +14 -delete
  • */5 means every five minutes; 5/5 means the same here, but 1/5 means 1, 6, 11 - the first value anchors the step.
  • Day-of-month and day-of-week are ORed when both are restricted: 0 0 1 * mon runs on the 1st and every Monday.
  • Names like @daily, @reboot and @weekly exist, but explicit fields document intent better.

The environment trap

Cron runs your command through /bin/sh with a nearly empty environment and a working directory of $HOME. Aliases, node version managers, virtual environments and interactive-only settings simply do not exist for the job.

#!/usr/bin/env bash
# a cron-friendly script: no assumptions about the caller
set -euo pipefail

export PATH="/usr/local/bin:/usr/bin:/bin"
cd /opt/app || exit 1
. /opt/app/venv/bin/activate

echo "$(date -Is) starting"
/opt/app/bin/run.sh
echo "$(date -Is) finished with status $?"
# prevent overlapping runs when a job can outlive its interval
*/5 * * * * /usr/bin/flock -n /var/lock/sync.lock /opt/app/sync.sh

# find out whether the job ran at all
journalctl -u cron --since today | grep sync
grep CRON /var/log/syslog | tail
  • Escape literal percent signs as \% in a crontab: cron turns an unescaped % into a newline and truncates the command.
  • Redirect both streams (>> log 2>&1) so failures leave evidence; otherwise cron mails the output to a mailbox nobody reads.
  • A job that dies silently at 03:00 is invisible until someone needs the data, so have it write a success line and alert when that line stops appearing.
⚠️
Cron does not run your shell profile. It starts /bin/sh with a minimal environment, so aliases, version managers and your PATH are all absent. Set the variables inside the script or the crontab itself, and never rely on the working directory being anything in particular.

systemd timers

# /etc/systemd/system/backup.service
[Unit]
Description=Nightly application backup

[Service]
Type=oneshot
User=deploy
WorkingDirectory=/opt/app
ExecStart=/opt/app/backup.sh

# /etc/systemd/system/backup.timer
[Unit]
Description=Run the backup every night

[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=600
Persistent=true
AccuracySec=1min

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer

systemctl list-timers --all
systemctl status backup.timer
journalctl -u backup.service -n 50 --no-pager

# dry-run an OnCalendar expression before trusting it
systemd-analyze calendar "Mon..Fri *-*-* 09:00:00"
systemd-analyze verify /etc/systemd/system/backup.timer
Needcronsystemd timer
Fixed wall-clock timesYesYes, via OnCalendar
Run after a service startsPainfulAfter=, Wants=
Catch up a missed runNoPersistent=true
Structured logsMail or your own redirectjournalctl -u
Randomised startManual sleepRandomizedDelaySec
Resource limitsNoMemoryMax, CPUQuota
Overlap protectionManual flockoneshot service, still one at a time
Requires a daemonNoYes
  • The timer activates the service: the service holds the work, the timer only holds the schedule.
  • Persistent=true records the last run, so a machine that was off at 02:30 runs the job after it boots.
  • Add flock or a lock file when a job can outlive its interval, since a timer will happily start a second copy.

FAQ

My cron job works by hand but not on schedule. Why?
Almost always environment or paths. Use absolute paths for every binary, set PATH in the crontab, redirect output to a log, and confirm the daemon is running (systemctl status cron). Look for the job in journalctl -u cron or /var/log/syslog.
Which should I choose for a new job?
Use a systemd timer when the host runs systemd, you want logs, retries or resource limits, or the job depends on another unit. Keep cron for portability across minimal images and for jobs that must exist where systemd does not.

systemd units and reading logs Archiving, syncing and backups

Last refreshed 2026-09-18.