cron runs commands on a schedule. It has done so since the 1970s, it is on every Linux system, and it fails in a small number of extremely predictable ways — nearly all of which come down to one thing: a cron job does not run in the environment you tested it in.
Editing your crontab
crontab -e # edit yours
crontab -l # list yours
crontab -r # DELETE yours, with no confirmation
sudo crontab -e -u www-data # edit another user's-r sits next to -e on the keyboard and deletes everything without asking. There is no undo and no trash. Keep your schedule in a file under version control and install it with crontab schedule.txt, or at minimum run crontab -l > ~/crontab.bak before editing.
The five fields
* * * * * command to run
│ │ │ │ │
│ │ │ │ └─ day of week (0-7, both 0 and 7 mean Sunday)
│ │ │ └─── month (1-12)
│ │ └───── day of month (1-31)
│ └─────── hour (0-23)
└───────── minute (0-59)| Syntax | Means |
|---|---|
* | Every value |
5 | Exactly 5 |
1,15,30 | A list |
9-17 | A range |
*/15 | Every 15 — a step |
0-30/5 | Every 5 within a range |
0 3 * * * /opt/backup.sh # 03:00 daily
*/15 * * * * /opt/check.sh # every 15 minutes
0 9 * * 1-5 /opt/report.sh # 09:00 on weekdays
30 2 1 * * /opt/monthly.sh # 02:30 on the 1st
0 */6 * * * /opt/sync.sh # every 6 hours
0 0 * * 0 /opt/weekly.sh # midnight on SundayDay of month AND day of week
A genuine oddity: if both day fields are restricted, cron runs the job when either matches, not both. So 0 0 13 * 5 runs on the 13th of every month and every Friday — not only on Friday the 13th. Every other combination of fields is an AND. This one is an OR.
Shorthand
| String | Equivalent |
|---|---|
@yearly | 0 0 1 1 * |
@monthly | 0 0 1 * * |
@weekly | 0 0 * * 0 |
@daily | 0 0 * * * |
@hourly | 0 * * * * |
@reboot | Once, at startup |
@reboot is handy but it is not a substitute for a service. If the command needs to stay running, keep restarting on failure, or start in a particular order relative to the network, write a systemd unit instead — see systemctl.
The environment problem
This causes more failed cron jobs than everything else combined. Your script works perfectly when you run it. At 3am it does nothing, silently.
cron does not read your .bashrc, .bash_profile or .profile. It runs with a small environment — five variables on a stock Ubuntu box — so anything your shell startup files set is gone, and LANG and XDG_RUNTIME_DIR are absent. PATH is the surprise: on Debian and Ubuntu it is not the traditional /usr/bin:/bin. Those distributions run cron -f -P, and -P tells cron not to overwrite what PAM set, so PATH comes from /etc/environment and is the full login PATH, /snap/bin included. Check yours with systemctl cat cron.service | grep ExecStart rather than assuming either. Use absolute paths anyway, because the next machine may not run cron with -P.
Three fixes, in order of preference:
# 1. Use absolute paths for everything
0 3 * * * /usr/local/bin/python3 /opt/app/backup.py
# 2. Set variables at the top of the crontab
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SHELL=/bin/bash
0 3 * * * backup.sh
# 3. Source the environment inside the job
0 3 * * * . $HOME/.profile; /opt/app/backup.shTo see exactly what your jobs get, schedule * * * * * env > /tmp/cronenv for a minute, then compare it with your shell’s env. It is usually a short and revealing list.
The other half of this: cron does not start in your home directory reliably, so relative paths inside the script are unsafe too. Have the script cd to where it needs to be as its first action.
Output and logging
By default cron emails any output to the user. On a server with no mail configured, that output vanishes — which is why failing jobs are so often silent. cron does say so, in a fashion: it logs (CRON) info (No MTA installed, discarding output). That line is at info priority, the same as “job started”, and it does not name which job’s output was discarded. If you have never seen it, that is because nothing you run looks at info.
# Log everything, stdout and stderr
0 3 * * * /opt/backup.sh >> /var/log/backup.log 2>&1
# Timestamp each run
0 3 * * * echo "--- $(date) ---" >> /var/log/backup.log; /opt/backup.sh >> /var/log/backup.log 2>&1
# Discard everything (only when you are certain)
0 3 * * * /opt/noisy.sh > /dev/null 2>&1
# Send failures somewhere useful
MAILTO="alerts@example.com"2>&1 must come after the redirect, or errors still escape. And > /dev/null 2>&1 on everything is a habit worth resisting — it is exactly how a backup silently stops working for eight months.
To confirm cron even tried:
grep CRON /var/log/syslog # Debian, Ubuntu
journalctl -u cron -f # Debian, Ubuntu (systemd)
journalctl -u crond -f # Fedora, RHEL, Rocky, AlmaThese log that a job started, not whether it succeeded — cron records no exit status at all, so exit 3 and exit 0 produce the same single line. A line in the log plus nothing happening means the script ran. It does not tell you whether the script failed or exited zero having done nothing, and the second is the more common of the two: The Life of a Scheduled Job is about the case where every instrument on the machine reports success and no work was done.
System crontabs
/etc/crontab and files in /etc/cron.d/ have an extra sixth field naming the user to run as:
# m h dom mon dow user command
0 3 * * * root /opt/backup.sh
*/5 * * * * www-data /opt/queue.shCopying a line from your personal crontab into /etc/cron.d/ without adding the user field is a common mistake — cron treats the first word of your command as a username and the job never runs.
The directories /etc/cron.daily/, cron.weekly/ and cron.hourly/ take executable scripts with no schedule line at all. Two things bite here: the file must be executable, and on many systems it must have no file extension — run-parts ignores anything containing a dot, so backup.sh is silently skipped where backup runs.
Gotchas
The percent sign
In a crontab, % means “newline” and everything after the first one becomes standard input to the command. This mangles any use of date:
# Broken
0 3 * * * tar -czf /backup/$(date +%F).tar.gz /data
# Correct — escape every percent
0 3 * * * tar -czf /backup/$(date +\%F).tar.gz /dataPutting the command in a script and calling the script avoids this entirely, which is the better habit. See tar for the archiving side.
Timezone
Jobs run in the system timezone, which on a cloud server is very often UTC rather than yours. Check with timedatectl. During daylight saving transitions, a job scheduled in the skipped hour does not run at all, and on cron one in the repeated hour may run twice — a reason to schedule important work outside 01:00–03:00. A systemd timer behaves differently at a fall-back: it fires once and moves to the next day, so the hazard there is not a double run but a twenty-five-hour gap between two consecutive runs of a “daily” job.
Overlapping runs
cron starts a job on schedule whether or not the previous run finished. A five-minute job on a two-minute schedule will pile up until the machine falls over. Guard it:
*/2 * * * * /usr/bin/flock -n /tmp/myjob.lock /opt/myjob.shflock -n simply exits if the lock is held, so a slow run is skipped rather than stacked.
Missed runs are gone
If the machine is off or asleep at 3am, the 3am job simply does not happen — cron never catches up. anacron handles that on laptops and desktops, and systemd timers do it natively with Persistent=true — once, however many runs were missed, rather than once per missed run. Note that anacron is not installed on a minimal Ubuntu server, and that its presence is what decides when /etc/cron.daily runs: /etc/crontab reads test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }, so installing anacron for an unrelated reason silently reschedules every daily job on the machine.
The file needs a trailing newline
crontab somefile refuses a file whose last line has no newline, and tells you so: new crontab file is missing newline before EOF, can't install. Your previous crontab is left in place. crontab -e handles it for you. The silent version of this — a final job quietly ignored — applies to hand-written files you drop into /etc/cron.d/, not to crontab.
cron or systemd timers?
Both work. The honest comparison:
| cron | systemd timers | |
|---|---|---|
| Setup | One line | Two unit files |
| Logging | Email, or roll your own | journalctl, automatically |
| Missed runs | Lost | Persistent=true runs one catch-up, not one per run missed |
| Dependencies | None | Full ordering and requirements |
| Overlap protection | flock, and the pile-up is loud | Built in, and quiet: your interval silently becomes the job’s duration |
| Exit status recorded | Never | Yes, for a non-zero exit |
| Portability | Everywhere, including containers | systemd only |
Use cron for simple, self-contained jobs and for anything that must be portable. Use timers when you need real logging, dependency ordering, or catch-up behaviour — which is most production work. The timer syntax is covered in the systemctl page.
Before you trust a new job
- Run the script by hand first — as the right user, with
sudo -u www-dataif needed. - Run it with a cleared environment:
env -i /bin/sh -c '/opt/backup.sh'. If it fails here, it will fail in cron. - Schedule it every minute temporarily and watch the log.
- Check the schedule expression itself —
*/15 * * * *and15 * * * *are very different, and only one of them is what you meant. - Set the real schedule, and confirm it fired once before walking away.
Quick reference
crontab -e # edit (careful: -r deletes everything)
crontab -l # list
crontab -l > ~/crontab.bak # back up before editing
sudo crontab -e -u user # another user's
0 3 * * * command # 03:00 daily
*/15 * * * * command # every 15 minutes
0 9 * * 1-5 command # weekday mornings
@reboot command # at startup
... command >> /var/log/x.log 2>&1 # always log
flock -n /tmp/x.lock command # prevent overlap
date +\%F # escape percent signs
grep CRON /var/log/syslog # did it fire
journalctl -u cron -f # same, systemd
timedatectl # confirm the timezoneRelated commands
- systemctl — timers, the modern alternative, with logging and catch-up built in.
- The Life of a Scheduled Job — the long one: the eight stages between the line you wrote and the person who needed last night’s output, and why nothing cron records separates a job that worked from one that ran and did nothing.
- tar — what most scheduled backup jobs actually run.
- Disk space — unrotated cron logs are a classic cause of a full disk.
at— for a job that should run once at a specific time rather than repeatedly.anacron— catch-up scheduling on machines that are not always on, and not installed by default on a server.flock— the standard way to stop scheduled jobs overlapping.
