Scheduling work: cron and systemd timers
Crontab syntax and its environment traps, then systemd timers with real logging and catch-up behaviour.
Crontab syntax
| Field | Range | Notes |
|---|---|---|
| Minute | 0-59 | */5 every five minutes |
| Hour | 0-23 | 24-hour clock, server time zone |
| Day of month | 1-31 | 1,15 for two days |
| Month | 1-12 | or jan, feb |
| Day of week | 0-7 | 0 and 7 are Sunday; mon-fri is clearer |
| Command | rest | Run 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*/5means every five minutes;5/5means the same here, but1/5means 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 * monruns on the 1st and every Monday. - Names like
@daily,@rebootand@weeklyexist, 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.targetsudo 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| Need | cron | systemd timer |
|---|---|---|
| Fixed wall-clock times | Yes | Yes, via OnCalendar |
| Run after a service starts | Painful | After=, Wants= |
| Catch up a missed run | No | Persistent=true |
| Structured logs | Mail or your own redirect | journalctl -u |
| Randomised start | Manual sleep | RandomizedDelaySec |
| Resource limits | No | MemoryMax, CPUQuota |
| Overlap protection | Manual flock | oneshot service, still one at a time |
| Requires a daemon | No | Yes |
- The timer activates the service: the service holds the work, the timer only holds the schedule.
Persistent=truerecords the last run, so a machine that was off at 02:30 runs the job after it boots.- Add
flockor 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.
Related
systemd units and reading logs Archiving, syncing and backups
Last refreshed 2026-09-18.