Cron Jobs and Task Scheduling with systemd Timers
I scheduled recurring tasks with cron and systemd timers, handling cron's minimal environment, logging, and failure notification. Capturing output and using absolute paths eliminated the classic "works in my shell but not in cron" failures.
Objective & Context
Scheduling is how routine work becomes hands-off. This lab covers crontab syntax, one-shot at jobs, and systemd timers (with logging and dependency control), the scheduling layer behind backups, health checks, and the vulnerability-scan automation.
Learning Objectives
- Write correct crontab entries using the five time fields.
- Log cron output so failures are visible instead of silent.
- Schedule one-shot jobs with at.
- Create systemd timer units with calendar expressions and Persistent=true.
- Choose between cron and systemd timers for a given workload.
Environment & Prerequisites
Prerequisites
- Linux with cron and systemd (cronie on RHEL/Fedora, cron on Debian/Ubuntu).
- A script to schedule, such as a backup or health check from earlier labs.
- A mail or webhook channel for failure alerts (see the CLI email lab).
- Root or sudo for system-wide timers; user crontabs need no elevation.
Step-by-Step Execution
1. Add a logged cron job
(crontab -l; echo "0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1") | crontab -Expected output:
(crontab installed; verify with: crontab -l)
0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
What this teaches: The five fields are minute hour day-of-month month day-of-week; appending the old crontab before adding preserves existing entries. Redirecting both streams to a log is what makes 2 AM failures diagnosable. On a fresh account crontab -l prints "no crontab for user" to stderr, which the pipe harmlessly ignores; add 2>/dev/null to silence it. One cron gotcha: a literal % in the command is converted to a newline, so escape it as \% or keep date formats inside a script file.
2. Equivalent systemd timer
A timer needs a service unit to trigger and a timer unit for the schedule. Create both as root, then reload and enable:
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup job
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup daily at 02:00
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload && sudo systemctl enable --now backup.timer && systemctl list-timers backup.timerExpected output:
NEXT LEFT LAST PASSED UNIT ACTIVATES
Tue 2026-06-18 02:00:00 UTC 10h left - - backup.timer backup.service
What this teaches: The timer unit pairs with a same-named .service; OnCalendar sets the schedule; Persistent=true runs missed jobs after downtime, which plain cron never does. daemon-reload is required after adding or editing unit files, or systemd keeps using the old definitions. systemctl list-timers shows the next trigger. Note the service runs the same absolute script path and should log like the cron version.
3. One-shot job with at
echo '/usr/local/bin/report.sh' | at 17:00Expected output:
job 14 at Tue Jun 17 17:00:00 2026
What this teaches: at queues a one-shot job for a future time, ideal for delayed one-time actions like a maintenance window reminder. atq lists the queue; atrm cancels. The at daemon must be running (systemctl status atd, package name at), or the job sits queued forever.
How It Works
cron is the classic scheduler: the daemon wakes every minute, matches the five time fields against the current time, and runs the command in a minimal environment. That minimal environment is the source of most cron bugs: PATH is tiny, no login profile runs, and the working directory is the user's home. Absolute paths and explicit environment are not style choices under cron; they are requirements.
at handles the one-shot case cron cannot express: run this once at 17:00, then forget it. The atd daemon holds the queue and executes jobs with the environment captured at submission time, which makes at handy for delayed actions during maintenance windows.
systemd timers are the modern alternative with real advantages: they log to the journal automatically, express dependencies (run after network is up), support monotonic triggers (10 minutes after boot), and Persistent=true catches up missed runs after downtime. For anything beyond trivial schedules, timers are the better tool.
Validation & Testing
Schedule a job a minute out, confirm it runs and logs, and force a failure to confirm the alert fires. Pass criteria: the job runs on schedule with full output captured, and non-zero exits trigger notification.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Cron job never runs | Wrong field count, or the daemon is not running | Validate with crontab -l and check systemctl status cron/crond |
| Works by hand, fails in cron | Minimal cron environment: missing PATH or variables | Use absolute paths and set needed variables inside the script or crontab |
| No output and no log | Output went to mail or nowhere | Always redirect >> /var/log/job.log 2>&1 so failures are captured |
| Timer runs at the wrong time | Timezone mismatch or OnCalendar misread | Check timedatectl and verify with systemd-analyze calendar '*-*-* 02:30:00' |
| Missed run never caught up | Persistent=true missing on the timer | Add Persistent=true under [Timer] for catch-up after downtime |
| Duplicate runs overlap | Job takes longer than its interval | Add locking with flock in the ExecStart line |
Advanced: Troubleshooting
- Job runs in shell, not cron: cron has a minimal PATH/env; use absolute paths and set variables.
- No output: redirect both streams (
>> log 2>&1) to capture errors. - Timer vs cron: prefer systemd timers for logging, dependencies, and missed-run catch-up.
Key Results
- Automated recurring tasks with logged cron and systemd timers.
- Eliminated cron-environment failures via absolute paths.
- Captured full job output for auditing and debugging.
- Alerted on non-zero exits rather than failing silently.
Key Takeaways
- Five cron fields: minute hour day-of-month month day-of-week.
- cron runs with a minimal environment; use absolute paths.
- Log every scheduled job; silent cron is undebuggable cron.
- at is for one-shot future jobs; cron and timers are for recurrence.
- systemd timers add logging, dependencies, and catch-up.
Practice Challenges
Challenge 1: Logged health check
Goal: schedule a disk-space check every 15 minutes that logs to a file and emails only when usage exceeds 85%.
Hint: the script branches on the threshold; cron just runs it.
Challenge 2: Timer conversion
Goal: convert an existing cron backup job to a systemd timer pair with Persistent=true, then prove a missed run catches up after a reboot.
Hint: set the timer in the past briefly during testing.