Cron is still the simplest way to run a command on a schedule, and it is also where scripts tend to fail without anyone noticing. This guide covers the schedule syntax, where cron jobs live, the environment differences that break scripts that work in your shell, and how to log and debug jobs. It is Part 3 of the Linux Server Baseline series. Examples use the cron package on Ubuntu 24.04 and Debian 12; RHEL and Fedora use cronie, and the differences that matter are noted.
Read the five schedule fields
Every cron line starts with five time fields, followed by the command:
| Field | Allowed values |
|---|---|
| Minute | 0-59 |
| Hour | 0-23 |
| Day of month | 1-31 |
| Month | 1-12, or jan to dec
|
| Day of week | 0-7, where 0 and 7 are both Sunday, or sun to sat
|
Each field accepts * for every value, a list (1,15), a range (1-5), or a step (*/15 means every 15 units from the start of the range, so minutes 0, 15, 30, and 45). Ranges and steps combine: 9-17/2 in the hour field means 9, 11, 13, 15, and 17.
| Expression | Runs |
|---|---|
*/15 * * * * |
Every 15 minutes |
0 9 * * 1-5 |
09:00, Monday to Friday |
30 2 * * * |
02:30 every day |
0 0 1 jan * |
Midnight on 1 January |
Names are case-insensitive three-letter abbreviations. Debian's crontab(5) man page says ranges and lists of names are not allowed, so the documented way to write mon-fri there is 1-5. Paste any expression into the cron parser to see when it will run before you install it.
Instead of five fields you can use a shortcut: @hourly (0 * * * *), @daily or @midnight (0 0 * * *), @weekly (0 0 * * 0), @monthly (0 0 1 * *), and @yearly or @annually (0 0 1 1 *). @reboot runs once when the cron daemon starts at boot, which can be before services such as your database are ready.
When both day fields are set
If day of month and day of week are both restricted (neither is *), cron runs the job when either one matches. 0 9 1 * 1 runs at 09:00 on the 1st of every month and also every Monday. To run on the first Monday only, restrict the day of month and test the weekday inside the command:
0 9 1-7 * * [ "$(date +\%u)" = 1 ] && /usr/local/bin/monthly-report
date +%u prints 1 for Monday. The backslash before % is required in a crontab line, as explained below.
Where cron jobs live
Each user has a personal crontab. Jobs in it run as that user, and it has no user field:
crontab -e
crontab -l
sudo crontab -u deploy -l
crontab -e opens it in your editor and checks the syntax when you save. The files are stored under /var/spool/cron/crontabs, but always go through the crontab command. Be careful with crontab -r: it deletes your whole crontab without asking, and r sits next to e on the keyboard. Keep a copy with crontab -l > ~/crontab.bak.
System crontabs are /etc/crontab and the files in /etc/cron.d. These have a user field between the schedule and the command:
15 3 * * * deploy /usr/local/bin/app-cleanup
Cron picks up changes to these files on its own, but it checks them first. On Debian and Ubuntu, files in /etc/cron.d must be owned by root, must not be writable by group or other, and must have names made only of letters, digits, underscores, and hyphens. A file called app-cleanup runs. A file called app-cleanup.cron is ignored without an error. The last line in any crontab must end with a newline.
The /etc/cron.hourly, cron.daily, cron.weekly, and cron.monthly directories hold scripts with no schedule line. /etc/crontab runs them through run-parts (or anacron runs them, if it is installed), and the times are in /etc/crontab. The scripts must be executable, and on Debian and Ubuntu the same naming rule applies, so backup.sh is skipped. Check what will actually run:
run-parts --test /etc/cron.daily
The cron environment is not your shell
A command that works in your terminal often fails under cron because cron gives it a much smaller environment:
-
PATHis minimal, so commands in/usr/local/binor/usr/sbinare not found by name. -
SHELLis/bin/sh, which is dash on Debian and Ubuntu, so bash syntax such as[[ ]]orsourcefails. - No profile is loaded. Nothing from
~/.bashrcor~/.profileexists, including variables you exported. - The working directory is the crontab owner's home directory, so relative paths resolve there.
-
%is special. Cron turns an unescaped%into a newline and sends everything after it to the command's standard input. Writedate +\%F, notdate +%F. Inside a script file this does not apply.
To see the exact environment on your system, add * * * * * env > /tmp/cron-env.txt to your crontab, wait a minute, read the file, and remove the line.
The fix is to use absolute paths and set the environment at the top of the crontab:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=ops@example.com
These lines are plain assignments. Cron does not expand variables in them, so PATH=$PATH:/opt/bin does not work.
Cron schedules jobs in the system time zone. Check it with timedatectl and look at the Time zone line. If you change it with sudo timedatectl set-timezone, restart cron with sudo systemctl restart cron so it uses the new zone. cronie on RHEL supports a CRON_TZ= line to schedule a crontab in a different zone. Debian's cron documents that it runs all jobs in a single time zone, so a TZ= line there only changes what the command sees, not when it runs.
Capture output and find the logs
Anything a job prints is mailed to the crontab owner, or to MAILTO. On a server with no mail transfer agent, Debian's cron logs No MTA installed, discarding output and the output is gone. Send it to a file instead:
0 * * * * /usr/local/bin/sync-files >> /home/deploy/logs/sync-files.log 2>&1
>> file 2>&1 appends both standard output and standard error. The order matters: 2>&1 >> file sends errors to the old standard output instead. The log path must be writable by the user the job runs as, which rules out /var/log for most user crontabs.
Cron itself logs a line each time it starts a job. On Ubuntu and Debian, read it from the journal:
sudo journalctl -u cron --since today
Sep 17 02:30:01 web1 CRON[48210]: (root) CMD (flock -n /run/lock/backup-app.lock /usr/local/bin/backup-app >> /var/log/backup-app.log 2>&1)
If rsyslog is installed, which is the case on Ubuntu 24.04 server but not on a default Debian 12 install, the same lines are in /var/log/syslog (grep CRON /var/log/syslog). On RHEL, use journalctl -u crond or /var/log/cron. A CMD line proves cron started the command. It says nothing about whether the command succeeded, which is why your own log matters.
A nightly backup with logging and locking
If a job can run longer than its interval, two copies end up running at once and writing the same files. flock -n prevents that: it takes a lock on a file and, if another process already holds it, exits immediately with status 1 instead of waiting. The simplest form is flock -n /tmp/job.lock command. For root jobs, /run/lock is a better home for lock files.
Here is a backup script at /usr/local/bin/backup-app:
#!/bin/bash
set -euo pipefail
DEST=/var/backups/app
stamp=$(date +%F-%H%M)
mkdir -p "$DEST"
echo "$(date -Is) backup started"
tar -czf "$DEST/app-$stamp.tar.gz" -C /srv/app data
find "$DEST" -name 'app-*.tar.gz' -mtime +14 -delete
echo "$(date -Is) backup finished"
set -euo pipefail stops the script at the first failed command, so a failed tar leaves a "started" line with no "finished" line after it, and old archives are not pruned. The % signs need no escaping because this is a script, not a crontab line.
Make it executable by root only. Mode 750 lets root run it while keeping it away from other users; Linux File Permissions and chmod, Explained covers what those digits mean.
sudo chown root:root /usr/local/bin/backup-app
sudo chmod 750 /usr/local/bin/backup-app
Run it once by hand with the lock, then check the log and the archive:
sudo flock -n /run/lock/backup-app.lock /usr/local/bin/backup-app
Then schedule it in /etc/cron.d/backup-app:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
30 2 * * * root flock -n /run/lock/backup-app.lock /usr/local/bin/backup-app >> /var/log/backup-app.log 2>&1
The next morning, tail /var/log/backup-app.log and ls -lh /var/backups/app tell you whether it worked. If the previous run still holds the lock, flock skips the new run and prints nothing, so a missing "started" line for a night means the job was skipped or never ran.
When a systemd timer fits better
systemd timers do the same job with two units. The service describes what to run:
# /etc/systemd/system/backup-app.service
[Unit]
Description=Nightly application backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-app
The timer says when:
# /etc/systemd/system/backup-app.timer
[Unit]
Description=Run backup-app nightly
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now backup-app.timer
systemctl list-timers backup-app.timer
Persistent=true runs the job at boot if a scheduled run was missed while the machine was off. Output goes to the journal (journalctl -u backup-app.service), and systemd will not start a second copy while the first is still running, so you need no flock or redirects. systemd-analyze calendar '*-*-* 02:30:00' checks an OnCalendar expression.
Cron is still fine for a one-line job on a server that is always on, for users managing their own jobs without root, and for images that do not run systemd.
Troubleshooting checklist
The job never runs
- Confirm the daemon is up with
systemctl status cron(crondon RHEL). - Look for a
CMDline at the expected time. If there is none, the problem is the schedule or the file, not the command. - Check the expression and the system time zone.
- For
/etc/cron.dandrun-partsdirectories, check the filename for dots, root ownership, and group or world write bits. - A user field in a personal crontab makes cron treat the username as the command. A missing user field in
/etc/cron.dmakes cron read the first word of the command as a username, and the job fails. - Make sure the file ends with a newline.
It runs by hand but not from cron
If the CMD line is there, cron did its part and the command failed. Read your job's log, then reproduce cron's environment:
env -i HOME="$HOME" LOGNAME="$USER" PATH=/usr/bin:/bin SHELL=/bin/sh /bin/sh -c '/usr/local/bin/sync-files'
The usual causes are a command not on PATH, an unescaped %, bash syntax running under sh, a relative path, or a variable that only exists in your login shell.
Too much email
Redirect output to a log file and set MAILTO="" at the top of the crontab. Avoid > /dev/null 2>&1 on jobs you care about, since it throws away the errors along with the noise.
Where to go next
Before you rely on a new job, confirm three things: the expression means what you think in the cron parser, a CMD line appears in the journal at the expected time, and the job's own log shows it finished. For the rest of the series, start with Hardening SSH for Production Servers.
Top comments (0)