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)
SyntaxMeans
*Every value
5Exactly 5
1,15,30A list
9-17A range
*/15Every 15 — a step
0-30/5Every 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 Sunday

Day 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

StringEquivalent
@yearly0 0 1 1 *
@monthly0 0 1 * *
@weekly0 0 * * 0
@daily0 0 * * *
@hourly0 * * * *
@rebootOnce, 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.sh

To 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, Alma

These 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.sh

Copying 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 extensionrun-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 /data

Putting 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.sh

flock -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:

cronsystemd timers
SetupOne lineTwo unit files
LoggingEmail, or roll your ownjournalctl, automatically
Missed runsLostPersistent=true runs one catch-up, not one per run missed
DependenciesNoneFull ordering and requirements
Overlap protectionflock, and the pile-up is loudBuilt in, and quiet: your interval silently becomes the job’s duration
Exit status recordedNeverYes, for a non-zero exit
PortabilityEverywhere, including containerssystemd 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

  1. Run the script by hand first — as the right user, with sudo -u www-data if needed.
  2. Run it with a cleared environment: env -i /bin/sh -c '/opt/backup.sh'. If it fails here, it will fail in cron.
  3. Schedule it every minute temporarily and watch the log.
  4. Check the schedule expression itself — */15 * * * * and 15 * * * * are very different, and only one of them is what you meant.
  5. 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 timezone

Related 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.