Cron is the scheduler that’s been on every Unix machine for forty years. You give it a time and a command, it runs the command at that time, and it keeps doing that until someone stops it.
The syntax takes ten minutes to learn. What takes longer is finding out why a job that works perfectly when you type it never runs when cron does. That part is nearly always the same handful of causes, so this covers them properly rather than leaving you to discover them at 3am.
The five fields
Every cron line is five time fields and then a command.
* * * * * command to run
| | | | |
| | | | +-- day of week (0-7, both 0 and 7 are Sunday)
| | | +---- month (1-12)
| | +------ day of month (1-31)
| +-------- hour (0-23)
+---------- minute (0-59)
An asterisk means every value. So * * * * * is every minute of every hour of every day, which is a fine thing to test with and a terrible thing to leave running.
Four characters do all the work:
| Character | Meaning | Example |
|---|---|---|
* |
Every value | * * * * * every minute |
, |
A list | 0 8,12,18 * * * at 8am, noon and 6pm |
- |
A range | 0 9-17 * * * hourly from 9 to 5 |
/ |
Steps | */15 * * * * every 15 minutes |
They combine. */10 9-17 * * 1-5 reads as every ten minutes, between 9am and 5pm, Monday to Friday.
Schedules you’ll actually use
| Schedule | When it runs |
|---|---|
*/5 * * * * |
Every 5 minutes |
0 * * * * |
Top of every hour |
0 2 * * * |
2am daily |
30 3 * * 0 |
3:30am on Sundays |
0 9 * * 1-5 |
9am on weekdays |
0 0 1 * * |
Midnight on the 1st |
0 0 * * 6 |
Midnight on Saturday |
15 22 * * * |
10:15pm daily |
0 */6 * * * |
Every 6 hours |
A note on the minute field, because it matters more than people expect. Writing 0 2 * * * for a nightly backup is fine on one machine. Do it on twenty machines that all talk to the same backup server and they’ll all start at once. Stagger them.
Editing your crontab
crontab -e # edit
crontab -l # list
crontab -r # delete the whole thing, no confirmation
Look at those last two for a second. -l and -r are one key apart, and -r wipes your entire crontab without asking. There’s no undo and no copy kept anywhere.
So before you edit anything on a machine that matters:
crontab -l > ~/crontab-$(date +\%F).bak
Restoring is crontab ~/crontab-2026-09-09.bak. Thirty seconds of insurance against a typo you can’t take back.
First run of crontab -e may ask which editor you want. If it drops you into vi and you’d rather it didn’t:
export EDITOR=nano
crontab -e
Shorthand schedules
| String | Equivalent |
|---|---|
@reboot |
Once, when the system starts |
@hourly |
0 * * * * |
@daily |
0 0 * * * |
@weekly |
0 0 * * 0 |
@monthly |
0 0 1 * * |
@yearly |
0 0 1 1 * |
@daily /usr/local/bin/backup.sh
@reboot /usr/local/bin/start-tunnel.sh
They read better than five asterisks, though @daily firing at exactly midnight is a busy moment on most systems. An explicit 17 3 * * * is often the better neighbour.
Where cron jobs live
| Location | What it’s for | User field? |
|---|---|---|
crontab -e |
Your own jobs | No |
/etc/crontab |
System-wide jobs | Yes |
/etc/cron.d/ |
Drop-in files, good for packages | Yes |
/etc/cron.daily/ |
Scripts run once a day | No, they’re scripts |
That user column is the thing to notice. Files in /etc/crontab and /etc/cron.d/ take an extra field between the schedule and the command, naming the user to run as:
# user crontab, five fields then the command
0 2 * * * /usr/local/bin/backup.sh
# /etc/cron.d/backup, five fields, then the USER, then the command
0 2 * * * root /usr/local/bin/backup.sh
Miss the user field in a cron.d file and the job silently never runs, because cron reads /usr/local/bin/backup.sh as the username. This is a genuinely common mistake and it produces no obvious error.
For anything a package installs or that belongs to the system, /etc/cron.d/ is tidier than the root crontab. One file per job, easy to deploy, easy to remove.
The PATH problem
This is why most cron jobs fail, so it’s worth understanding rather than working around.
Your interactive shell loads a profile that sets a generous PATH. Cron does not. It runs with a minimal environment, typically just /usr/bin:/bin, and it doesn’t read your .bashrc or .profile. So a script that runs perfectly when you type it can fail under cron because a command it calls isn’t on cron’s PATH.
You can see exactly what cron gets. Schedule this for a minute from now:
* * * * * env > /tmp/cron-env.txt
$ cat /tmp/cron-env.txt
SHELL=/bin/sh
PATH=/usr/bin:/bin
PWD=/home/asif
HOME=/home/asif
LOGNAME=asif
That’s it. No /usr/local/bin, which is exactly where a lot of tools install themselves.
Two fixes, and the first is better:
# 1. absolute paths, always
0 2 * * * /usr/local/bin/aws s3 sync /data s3://bucket/
# 2. or set PATH at the top of your crontab
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 2 * * * aws s3 sync /data s3://bucket/
Use which aws in your shell to get the full path. Absolute paths are self-documenting and they don’t break when someone changes the crontab header later.
The same applies inside your scripts. A script that works when you run it may still fail from cron because of a command five lines down.
The percent sign will bite you
In a crontab, % is not a normal character. Cron translates it into a newline, which means everything after it becomes input to the command rather than part of it.
So this looks completely reasonable and does not work:
0 2 * * * tar -czf /backup/db-$(date +%F).tar.gz /var/lib/mysql
Escape every percent sign with a backslash:
0 2 * * * tar -czf /backup/db-$(date +\%F).tar.gz /var/lib/mysql
Any date format string in a crontab needs this. It catches out people who have used cron for years, because the command is correct everywhere except inside a crontab.
The cleaner answer for anything non-trivial is to put the logic in a script and schedule the script. Then it’s ordinary shell and the percent rule stops applying.
Day of month and day of week is an OR
Here’s one that surprises people. If you restrict both the day-of-month field and the day-of-week field, cron runs the job when either matches, not when both do.
0 0 1 * 1 # NOT "the 1st, if it is a Monday"
# runs on the 1st, AND on every Monday
That’s specified behaviour going back to the original implementations, not a bug. As long as one of the two fields is * the rule never comes up, which is why most people never meet it. If you genuinely need “the 1st only when it’s a Monday”, test for it in the script:
0 0 1 * * [ "$(date +\%u)" = "1" ] && /usr/local/bin/job.sh
Output, mail and logging
By default cron mails any output to the job’s owner. On most modern servers no mail system is configured, so that output goes nowhere and you never learn the job printed an error.
Send it somewhere you’ll actually look:
0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
2>&1 sends errors to the same place as normal output, and it has to come after the redirect. Written the other way round it doesn’t do what you want.
You’ll also see this everywhere:
0 2 * * * /usr/local/bin/backup.sh > /dev/null 2>&1
That throws away everything including errors. Fine for a job you genuinely don’t care about. For a backup it means the first you hear about a failure is when you need a restore.
To control mail explicitly, set MAILTO at the top of the crontab. An empty value turns mail off:
MAILTO="asif@example.com"
MAILTO=""
Working out why a job didn’t run
In order, because this sequence finds it almost every time.
Is cron even running?
systemctl status cron # Debian, Ubuntu
systemctl status crond # RHEL, Rocky, Alma, Fedora
Did cron try? The log tells you whether the job fired, which separates a scheduling problem from a script problem.
$ journalctl -u cron --since "1 hour ago"
Sep 09 02:00:01 web01 CRON[8412]: (asif) CMD (/usr/local/bin/backup.sh)
On older systems it’s /var/log/syslog on Debian family and /var/log/cron on RHEL family.
If you see the CMD line, cron did its job and the problem is in your script. If you don’t, the schedule or the crontab itself is wrong.
Run it the way cron would. This is the step that finds PATH problems immediately:
env -i /bin/sh -c "/usr/local/bin/backup.sh"
env -i strips the environment, so you get roughly what cron gets. If it fails here and works normally, it’s environment.
Check the boring things. Is the script executable (chmod +x)? Does it have a shebang line? Does the crontab end with a newline, since some cron implementations ignore a final line without one? Did you edit a file in /etc/cron.d/ and forget the user field?
Stopping jobs from piling up
A job scheduled every five minutes that sometimes takes seven will start overlapping itself. Two copies, then three, and on a backup or a sync that’s how you corrupt something or run a machine out of memory.
flock fixes it in one wrapper:
*/5 * * * * /usr/bin/flock -n /tmp/sync.lock /usr/local/bin/sync.sh
-n means don’t wait. If the previous run still holds the lock, this one exits immediately instead of queueing. For any job that could conceivably run long, this is worth adding before you find out the hard way.
Timezones
Cron uses the system timezone, which matters on servers set to UTC while you’re thinking in local time. Check with timedatectl. You can override it per crontab on most Linux distributions:
CRON_TZ=Europe/London
0 9 * * 1-5 /usr/local/bin/report.sh
Be careful scheduling anything between midnight and 3am in a timezone with daylight saving. That window either happens twice or not at all, once a year, and cron’s behaviour there has produced some memorable outages. UTC avoids the whole problem.
Three jobs worth stealing
Nightly backup to another machine, using rsync so only changes transfer:
15 2 * * * /usr/bin/rsync -aAX --delete /var/www/ backup@nas:/backups/www/ >> /var/log/backup.log 2>&1
Clear out old logs weekly:
0 4 * * 0 /usr/bin/find /var/log/app -name "*.log" -mtime +30 -delete
Test that find command by hand without -delete first, and read what it prints. A cron job doesn’t fail once, it fails quietly every week.
Check certificate renewal twice a day, staggered off the hour so you’re not hitting the CA at the same minute as everyone else:
23 3,15 * * * /usr/bin/certbot renew --quiet
When to use a systemd timer instead
Cron is fine and it isn’t going anywhere. But systemd timers do several things cron can’t, and on a modern distribution they’re worth knowing.
| You want | Use |
|---|---|
| Something simple, quickly | cron |
| Portability to any Unix | cron |
| Missed runs to catch up after downtime | systemd timer |
| Proper logs in the journal | systemd timer |
| Memory or CPU limits on the job | systemd timer |
| To depend on another service being up | systemd timer |
That catch-up behaviour is the real differentiator. If a server is off at 2am, the cron job simply doesn’t happen. A timer with Persistent=true runs it as soon as the machine comes back.
# /etc/systemd/system/backup.timer
[Unit]
Description=Nightly backup
[Timer]
OnCalendar=*-*-* 02:15:00
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl enable --now backup.timer
systemctl list-timers
That pairs with a backup.service unit holding the actual command. More on units in understanding systemd service management.
Troubleshooting
| What you see | What it usually means |
|---|---|
| Job never runs, nothing in the log | Schedule is wrong, or a cron.d file is missing its user field |
| Log shows CMD but nothing happened | Script ran and failed. Redirect output and read it. |
| Works by hand, fails in cron | PATH. Use absolute paths. |
| Filename comes out mangled | Unescaped % in a date format |
| Runs on unexpected days | Day-of-month and day-of-week are an OR |
| Several copies running at once | Job outlasts its interval. Wrap it in flock. |
| Ran at the wrong time | Server timezone. Check timedatectl. |
crontab -r and now it’s empty |
No undo. Restore from your backup file. |
Wrapping up
The schedule syntax is the easy half and you’ll have it after a couple of jobs. The half worth internalising is that cron runs your command in a stripped environment, tells nobody when it fails, and does exactly what you wrote rather than what you meant.
Two habits cover most of it. Use absolute paths for everything, and send output to a log file you’ll actually read. If the shell side is still new, Linux commands and directory structure is a good place to build up from.


