Linux Cron Jobs and systemd Timers
Master scheduled tasks on Linux. Learn crontab syntax, fix common PATH and environment pitfalls, and use systemd timers as a modern alternative.
What you'll learn
- ✓How to read and write crontab entries correctly
- ✓The most common reasons cron jobs silently fail
- ✓How to log and debug cron jobs in production
- ✓How systemd timers work and when to prefer them
- ✓How to convert a cron entry into a timer + service pair
Prerequisites
- •Comfort with [essential commands](/blog/linux-essential-commands)
- •Basics of [shell scripting](/blog/linux-shell-scripting-basics)
Scheduled tasks are the dark matter of a Linux system: invisible most of the time, doing most of the maintenance work. Cron has been around for decades and still ships everywhere. systemd timers are the modern alternative, with better logging and dependency control. You should know both.
Crontab syntax
A crontab line has five time fields and a command:
# m h dom mon dow command
* * * * * /path/to/job
| Field | Range |
|---|---|
| Minute | 0-59 |
| Hour | 0-23 |
| Day of month | 1-31 |
| Month | 1-12 |
| Day of week | 0-7 (0 and 7 both mean Sunday) |
A few realistic examples:
# Every 5 minutes
*/5 * * * * /opt/codeloom/bin/sync.sh
# Every day at 03:15
15 3 * * * /opt/codeloom/bin/backup.sh
# Every Monday at 09:00
0 9 * * 1 /opt/codeloom/bin/weekly-report.sh
# First of the month at 00:30
30 0 1 * * /opt/codeloom/bin/rotate.sh
A subtle rule: if both day of month and day of week are restricted (neither is *), the job runs when either condition matches, not both. This bites people regularly.
Edit a user crontab with crontab -e. List with crontab -l. System-wide jobs live in /etc/crontab and /etc/cron.d/. Files in /etc/cron.daily/, /etc/cron.hourly/, and so on are executable scripts run on the obvious schedule by the cron daemon.
The pitfalls that break cron jobs
Cron jobs fail in characteristic ways. Here are the four that account for most incidents.
1. The PATH is not your PATH
Cron runs jobs with a minimal environment. On most systems PATH is /usr/bin:/bin. If your script calls node, python3, or anything in /usr/local/bin, it will not be found. Fix it by being explicit:
PATH=/usr/local/bin:/usr/bin:/bin
*/5 * * * * /opt/codeloom/bin/sync.sh
Or use absolute paths in your script. Better, do both.
2. Environment variables are not loaded
Cron does not read .bashrc or .profile. Any environment your job needs must be set in the crontab itself or sourced inside the script:
#!/usr/bin/env bash
set -euo pipefail
source /etc/codeloom/env
exec /opt/codeloom/.venv/bin/python /opt/codeloom/job.py
3. Output is mailed, not logged
By default cron mails stdout and stderr to the user. On most servers no MTA is configured, so the output is dropped or piles up. Always redirect:
*/5 * * * * /opt/codeloom/bin/sync.sh >> /var/log/codeloom/sync.log 2>&1
Or use logger to push into the journal:
*/5 * * * * /opt/codeloom/bin/sync.sh 2>&1 | logger -t codeloom-sync
4. The job is not idempotent
Cron will happily start a second copy of your job while the first is still running. For long jobs, use flock:
*/5 * * * * flock -n /tmp/codeloom-sync.lock /opt/codeloom/bin/sync.sh
-n makes flock exit immediately if the lock is held, instead of queueing.
Debugging a cron job
Symptoms first, then layers:
- Confirm cron itself is running:
systemctl status cronorcrond. - Confirm your line is loaded:
crontab -lfor users,cat /etc/crontabandls /etc/cron.d/for system. - Look at cron’s own logs:
journalctl -u cron -S '1 hour ago'(or/var/log/syslogon older systems). - Run the command line manually with the cron environment:
env -i HOME="$HOME" PATH=/usr/bin:/bin SHELL=/bin/sh /opt/codeloom/bin/sync.sh
If it fails there but works in your shell, you have an environment problem. If it works there but not from cron, you have a permissions problem (cron may be running as a different user).
systemd timers
A timer is a unit that triggers another unit on a schedule. You need two files: the service that does the work and the timer that schedules it.
/etc/systemd/system/codeloom-sync.service:
[Unit]
Description=Codeloom sync job
[Service]
Type=oneshot
User=codeloom
EnvironmentFile=/etc/codeloom/env
ExecStart=/opt/codeloom/bin/sync.sh
/etc/systemd/system/codeloom-sync.timer:
[Unit]
Description=Run codeloom sync every 5 minutes
[Timer]
OnCalendar=*:0/5
Persistent=true
AccuracySec=30s
Unit=codeloom-sync.service
[Install]
WantedBy=timers.target
Activate:
sudo systemctl daemon-reload
sudo systemctl enable --now codeloom-sync.timer
systemctl list-timers --all | grep codeloom
OnCalendar syntax is more expressive than cron. A few examples:
*-*-* 03:15:00 # every day at 03:15
Mon *-*-* 09:00:00 # every Monday at 09:00
*-*-01 00:30:00 # first of every month at 00:30
hourly # shorthand for *-*-* *:00:00
You can test an expression without scheduling anything:
systemd-analyze calendar 'Mon *-*-* 09:00:00'
Why timers, when cron works
Timers are not strictly better, but they have real advantages:
- Logs in the journal.
journalctl -u codeloom-sync.servicegives you every run, with exit codes, stdout, and stderr, indexed by time. - No environment surprises. The service runs with the environment you specify in the unit, not a stripped-down cron shell.
- Dependencies. The job can wait for the network, a mount, or another service.
- Catch-up.
Persistent=trueruns the job once at boot if a scheduled run was missed while the machine was off. cron has no equivalent. - Easy isolation. The same
Protect*andPrivate*options from systemd basics work here.
The downside is two files instead of one line. For trivial jobs, cron still wins on brevity.
A reasonable rule of thumb
Use cron for tiny personal jobs and quick admin tasks. Use timers when the job is part of a service you ship, when it needs to log reliably, or when it depends on other units. Many production setups run both, and that is fine.
When you do use cron, write the script with the same discipline as any other shell script: shebang, set -euo pipefail, absolute paths, explicit logging. The shell scripting basics article covers the patterns.
Wrap up
Cron is simple, ubiquitous, and full of foot-guns: PATH, environment, output, and concurrency. Handle those four and your jobs will run for years without anyone noticing. systemd timers solve the same problem with two unit files instead of one line, and pay you back with logs, dependencies, and missed-run recovery. Pick the tool that matches the job and write it once, correctly.