Linux concept: cron
Almost every server runs work that nobody watches. A backup at three in the morning, a certificate renewal twice a month, a cache that gets cleared every hour. The program that makes that happen has been doing the same job since 1979, in almost the same way, and it is called cron. It is also one of the easiest tools on a Linux system to get subtly wrong, because a broken cron job and a working cron job look exactly the same: silent.
1. The Basics
cron is a daemon. It starts when the machine boots and then does something almost comically simple: once a minute it wakes up, looks at the clock, and compares that to a list of rules. Every rule that matches the current minute gets run. Then it goes back to sleep for the rest of the minute.
That is the whole design. There is no queue, no scheduler in the clever sense, no catching up on work that was missed. If the machine was switched off at 03:00, the 03:00 job simply did not happen.
1.1 Checking That It Runs
Before anything else, confirm the daemon is actually there. On Debian and Ubuntu the package and the service are both called cron:
$ systemctl is-active cron
active
$ systemctl is-enabled cron
enabled
On Red Hat family systems the package is cronie and the service is crond. The rules in this article apply to both, because both descend from the same original program.
1.2 The Three Places Jobs Live
This is the part that confuses people the most. There is not one list of cron jobs; there are three kinds of place, and they do not have the same format.
| Where | Belongs to | Format |
|---|---|---|
/var/spool/cron/crontabs/<user> |
One user | Five time fields, then the command. Edited with crontab -e, never by hand. |
/etc/crontab and /etc/cron.d/* |
The system, usually packages | Five time fields, then a user name, then the command. Edited directly. |
/etc/cron.{hourly,daily,weekly,monthly}/ |
The system | No time fields at all. Just executable scripts, run by run-parts. |
The sixth field in the middle group is the single most common source of "my cron job does not run". A line copied from a user crontab into /etc/cron.d will be read with the first word of the command treated as a user name, and it will fail.
You can see all three on a normal machine:
$ crontab -l # your own jobs
$ cat /etc/crontab # the system table
$ ls /etc/cron.d/ # one file per package
anacron e2scrub_all php sysstat
$ ls /etc/cron.daily/ # scripts, no schedule inside them
0anacron apt-compat dpkg logrotate man-db plocate
Back to topThe right mental model: cron is an alarm clock, not a to-do list. It fires at a moment in time. It does not know whether the job succeeded, whether it is still running from last time, or whether it was missed while the machine was off.
2. Where the Name Comes From
The name is generally taken to come from the Greek chronos, time. The author of the original program never wrote the derivation down, so treat that as the accepted explanation rather than a documented fact. What the surrounding names mean is not in doubt:
cron the daemon that watches the clock
crontab cron TABle - both the file and the command that edits it
cron.d a drop-in directory of cron fragments
anacron "anachronistic" cron: for machines that are not always on
The trailing d in crond on Red Hat systems is the usual Unix suffix for a daemon, the same one you see in sshd, httpd and systemd. Debian and Ubuntu simply left it off.
One naming detail matters in practice. Because both the file and the command are called crontab, the manual has two different pages for them, in two different sections:
$ man 1 crontab # the command: -l, -e, -r
$ man 5 crontab # the file format: the five fields
Almost everything people search the web for is in crontab(5), and almost nobody opens it.
3. A Short History
cron appeared in Version 7 Unix in 1979. That first version was even simpler than today's: a single system-wide table, read by a daemon that woke up every minute. There were no per-user crontabs and no crontab command, because there was only one file and only the administrator could edit it.
The version you are almost certainly running was written by Paul Vixie in 1987. It is still the one shipping today, and the version number is refreshingly honest about its age:
$ dpkg -l cron | tail -1
ii cron 3.0pl1-184ubuntu2 amd64 process scheduling daemon
That "3.0pl1" is Vixie cron 3.0, patch level 1. The 184 after it is Debian's patch count, which tells you where most of the last thirty years of work actually went.
| Era | Milestone |
|---|---|
| 1979 | Version 7 Unix ships cron: one table, one daemon, one minute at a time |
| 1980s | System V adds per-user crontabs, the crontab command, and cron.allow / cron.deny |
| 1987 | Paul Vixie releases his cron, with ranges, steps, names and the @daily shorthands |
| 1990s-2000s | Debian adds /etc/cron.d, PAM support, daylight-saving handling, and makes crontab setgid rather than setuid root |
| Along the way | anacron covers the case cron cannot: machines that are switched off |
| 2010s | systemd timers arrive; packages begin shipping both, with the cron copy disabled |
The Debian changes are worth one more sentence, because they explain a permission that looks alarming until you know why:
$ ls -l /usr/bin/crontab
-rwxr-sr-x 1 root crontab 39664 Mar 31 2024 /usr/bin/crontab
That s in the group column is setgid. The command runs as group crontab, which is the only group allowed to write into the spool directory. Upstream Vixie cron made this program setuid root; Debian narrowed it to a group that can do exactly one thing. That is why you must use crontab -e instead of editing the spool file yourself: the file is not yours to write.
4. Simple Use Cases
4.1 The Five Fields
A line in a user crontab is five time fields and then everything else, which is the command. The comment block that Debian ships in /etc/crontab is the best diagram of it, and it is already on your machine:
# .---------------- minute (0 - 59)
# | .------------- hour (0 - 23)
# | | .---------- day of month (1 - 31)
# | | | .------- month (1 - 12) OR jan,feb,mar,apr ...
# | | | | .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,...
# | | | | |
# * * * * * command to be executed
Read a line right to left and it says itself out loud. This one runs at 03:30 every day:
30 3 * * * /usr/local/bin/backup.sh
An asterisk means "every value of this field". So the line above is: minute 30, hour 3, every day of the month, every month, every day of the week.
Both 0 and 7 mean Sunday in the day-of-week field, which is a historical compromise between two Unix families that disagreed. You can also write three-letter names, in either case:
0 4 * * 1 # Monday at 04:00
0 4 * * mon # exactly the same
0 4 * * MON # also the same, case does not matter
Names are not allowed in ranges or lists, so mon-fri works but a mixed list of names and numbers does not. When in doubt, use numbers.
4.2 Editing Your Own Crontab
Three commands cover almost everything:
| Command | Does |
|---|---|
crontab -l |
List your crontab (short for list) |
crontab -e |
Edit it in $EDITOR (short for edit) |
crontab -r |
Delete it entirely (short for remove) |
crontab -u user -l |
Work on another user's crontab (root only) |
crontab file |
Replace your crontab with the contents of a file |
Look at -e and -r on a keyboard. They are next to each other, they do very different things, and there is no undo. This is a genuinely famous way to lose an afternoon. Two habits protect you:
$ crontab -l > ~/crontab.backup # before you touch anything
$ crontab -i -r # -i asks for confirmation first
The last row of the table is worth knowing for another reason: crontab file means your jobs can live in version control. Keep the real file in a repository, and install it with one command:
$ crontab -n deploy.cron # dry run: check the syntax, install nothing
$ crontab deploy.cron # replace the live crontab with this file
The -n flag (short for no action) is not widely known and is exactly what you want in a deployment script: it parses the file, reports any error, and exits without changing anything.
4.3 Where the Output Goes
This is the single most important thing to understand about cron, and it has nothing to do with the schedule.
Anything a cron job prints is treated as an error report. If a job writes to standard output or standard error, cron mails that text to the owner of the crontab. If the job prints nothing, cron says nothing.
On a desktop or a modern server there is usually no mail system installed, so that mail goes nowhere at all. Your job runs, prints an error, and the error is discarded. This is why broken cron jobs are silent.
The fix is to decide where the output goes yourself:
30 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
Read the tail of that line carefully, because the order matters:
| Part | Means |
|---|---|
>> |
Append to the log instead of overwriting it every night |
/var/log/backup.log |
Standard output goes here |
2>&1 |
Send standard error to the same place standard output is already going |
Writing 2>&1 >> file instead is a classic mistake: it points error output at the terminal cron gave you (which is nothing), and only then redirects standard output. The redirection has to come first.
You will also see > /dev/null 2>&1 on a lot of lines found on the internet. That silences the job completely. It is the right choice for a job that does its own logging, and the wrong choice for everything else, because it throws away the only warning you were going to get.
4.4 The Shorthands
Vixie cron added eight special strings that replace all five fields:
| String | Same as | Meaning |
|---|---|---|
@hourly |
0 * * * * |
Top of every hour |
@daily / @midnight |
0 0 * * * |
Every day at midnight |
@weekly |
0 0 * * 0 |
Sunday at midnight |
@monthly |
0 0 1 * * |
First of the month at midnight |
@yearly / @annually |
0 0 1 1 * |
1 January at midnight |
@reboot |
- | Once, when cron itself starts |
They read well, but there is a catch worth knowing before you use them on a busy server: every @daily job on the machine fires at exactly 00:00, and every @hourly job at exactly minute 0. Spreading load is a good reason to write the fields out and pick an odd minute.
@reboot has a warning in its own manual page. It runs when the cron daemon starts, which during boot may be before the network, the database, or the filesystem your job needs. For anything with real dependencies, a systemd unit is the correct tool, and section 6.10 shows why.
5. Moderate Use Cases
5.1 Ranges, Lists and Steps
Each of the five fields accepts more than a number or an asterisk:
| Syntax | Name | Example | Means |
|---|---|---|---|
a-b |
Range, inclusive | 8-11 |
Hours 8, 9, 10 and 11 |
a,b,c |
List | 0,15,30,45 |
Four times an hour |
*/n |
Step over the whole field | */5 |
Every fifth value: 0, 5, 10, ... |
a-b/n |
Step inside a range | 9-17/2 |
9, 11, 13, 15, 17 |
Ranges and lists combine freely, which older Unix crons did not allow:
*/10 9-17 * * 1-5 /usr/local/bin/check.sh # every 10 min, office hours, weekdays
0 2 1,15 * * /usr/local/bin/report.sh # 02:00 on the 1st and the 15th
15 */6 * * * /usr/local/bin/sync.sh # 00:15, 06:15, 12:15, 18:15
There is a trap hiding in step values that section 7.4 takes apart. In short: a step counts inside its field's range and then restarts, so */7 in the minute field does not give you an even gap of seven minutes across the hour boundary.
5.2 System Jobs: The Sixth Field
/etc/crontab and every file in /etc/cron.d use the same five time fields, and then a user name before the command. This is what lets one file schedule work as different users:
# /etc/cron.d/mysite
# m h dom mon dow user command
17 * * * * www-data /usr/local/bin/cache-warm.sh
30 3 * * * root /usr/local/bin/backup.sh
Files in /etc/cron.d are the right place for anything a server needs, for three reasons. They are ordinary files, so configuration management and version control can deploy them. They survive a user being deleted. And cron notices changes to them automatically, so there is nothing to reload.
Four rules apply to these files, and breaking any one of them means silent failure:
- They must be owned by
rootand must not be group- or world-writable. - They must include the user field. Forgetting it is the most common mistake in this directory.
- They do not inherit environment settings from
/etc/crontab. Each file stands alone. - The filename must not contain a dot. Section 7.3 shows what happens when it does.
Here is a real one from this machine, shipped by the PHP package:
$ cat /etc/cron.d/php
# Look for and purge old sessions every 30 minutes
09,39 * * * * root [ -x /usr/lib/php/sessionclean ] && if [ ! -d /run/systemd/system ]; then /usr/lib/php/sessionclean; fi
Note the test in the middle. On a machine running systemd, this cron job deliberately does nothing, because a systemd timer does the same work instead. That is worth remembering when you are trying to work out why a job that clearly exists never seems to run.
5.3 The run-parts Directories
The four directories /etc/cron.hourly, cron.daily, cron.weekly and cron.monthly contain no schedules at all. They contain executable scripts. /etc/crontab schedules a program called run-parts to execute everything in them:
$ cat /etc/crontab
17 * * * * root cd / && run-parts --report /etc/cron.hourly
25 6 * * * root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }
47 6 * * 7 root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.weekly; }
52 6 1 * * root test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.monthly; }
Three things are visible in those four lines. The daily, weekly and monthly runs are skipped entirely if anacron is installed, because then anacron owns them. Everything runs between 06:00 and 07:00 by default, so a machine that is off at that time never runs its daily jobs. And the scripts run from /, not from your home directory.
To drop your own script in, make it executable and give it a name with no extension:
$ sudo cp cleanup /etc/cron.daily/cleanup # note: no .sh
$ sudo chmod +x /etc/cron.daily/cleanup
Then confirm it. run-parts --test prints exactly the list of scripts that would be executed, in order, and executes none of them:
$ run-parts --test /etc/cron.daily
/etc/cron.daily/0anacron
/etc/cron.daily/apport
/etc/cron.daily/apt-compat
/etc/cron.daily/dpkg
/etc/cron.daily/google-chrome
/etc/cron.daily/logrotate
/etc/cron.daily/man-db
/etc/cron.daily/plocate
/etc/cron.daily/slack
/etc/cron.daily/sysstat
This is the check that saves you. If your script is not in that list, it will never run, and section 7.3 explains the usual reason. Note also that 0anacron sorts to the front deliberately: run-parts executes in name order, so a leading digit is how a package makes sure it goes first.
5.4 Sending Output Somewhere Useful
Two environment variables at the top of a crontab change where the output goes. They apply to every line below them:
MAILTO=This email address is being protected from spambots. You need JavaScript enabled to view it. # mail job output here instead of to the crontab owner
MAILTO="" # send no mail at all, for every job in this file
0 3 * * * /usr/local/bin/backup.sh
MAILTO only helps if the machine can actually send mail. On a server with no mail transfer agent, the mail is generated and then dropped. If you want to know whether a job failed, do not rely on it. Log to a file you can read, or have the job report to a monitoring service when it finishes.
A log file you append to every five minutes grows forever, and a full disk caused by a logging cron job is an unusually annoying way to take a server down. Whatever you redirect into needs rotating. Drop a file in /etc/logrotate.d next to the ones the packages ship:
$ cat /etc/logrotate.d/myjob
/var/log/myjob.log {
rotate 14
daily
compress
missingok
notifempty
}
The alternative is not to own a file at all. Pipe the output into logger and it becomes ordinary syslog, tagged so you can find it, rotated by whatever already rotates the system logs:
0 3 * * * /usr/local/bin/backup.sh 2>&1 | /usr/bin/logger -t backup
$ journalctl -t backup --since today
The -t flag (short for tag) is what makes this worth doing: it labels every line so journalctl -t backup gives you that job and nothing else. Note that piping to logger hides the script's exit status behind the pipe, so use it together with the marker file in section 6.9 rather than instead of it.
5.5 Checking That It Ran
cron logs to syslog under the facility cron, and its child processes rename themselves to uppercase CRON, which is what you search for:
$ journalctl -t CRON --since '-1 hour'
Aug 23 15:35:01 xps CRON[408129]: (root) CMD (command -v debian-sa1 > /dev/null && debian-sa1 1 1)
$ journalctl -u cron --since today # the daemon's own messages
$ grep CRON /var/log/syslog # on systems with rsyslog
By default cron logs only that a job started. It does not log the exit status, which is why a log full of CMD lines is not proof that anything worked. You can change that. The -L flag takes a bitmask, and 4 adds failed jobs:
| Value | Logs |
|---|---|
| 1 | The start of every job (the default) |
| 2 | The end of every job |
| 4 | Every job that exits non-zero |
| 8 | The process number of every job |
| 15 | All of the above |
Most guides tell you to set EXTRA_OPTS in /etc/default/cron. On current Ubuntu that advice is out of date, and the file says so itself:
$ cat /etc/default/cron
# This file has been deprecated. Please add custom options for cron using
# $ systemctl edit cron.service
# or
# $ systemctl edit --full cron.service
The current way is a systemd drop-in. Run sudo systemctl edit cron.service and add the two lines below. The empty ExecStart= is required: it clears the existing command before you give a new one, and without it systemd refuses the unit.
[Service]
ExecStart=
ExecStart=/usr/sbin/cron -f -P -L 15
Then sudo systemctl restart cron. Turning this on for a day is often the fastest way to settle an argument about whether a job ran.
6. Advanced Use Cases
6.1 The Environment a Cron Job Really Gets
"It works when I run it by hand but not from cron" is the most common cron complaint there is, and the environment is almost always the reason. Rather than repeat the folklore, here is a measurement. This crontab ran on the machine this article was written on:
$ crontab -l
* * * * * /usr/bin/env > /tmp/cron-env.txt 2>&1
$ cat /tmp/cron-env.txt
HOME=/home/peter
LOGNAME=peter
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
LANG=en_US.UTF-8
SHELL=/bin/sh
PWD=/home/peter
Six variables. That is the entire world your job runs in. Three details in that list matter more than the rest.
The PATH advice you have read is probably out of date. Every cron tutorial says cron gives you a crippled PATH of /usr/bin:/bin. That is not what happened here, and the reason is visible in the service definition:
$ systemctl cat cron.service | grep ExecStart
ExecStart=/usr/sbin/cron -f -P $EXTRA_OPTS
The -P flag means "do not set PATH for child processes, let it inherit instead". So on this system the value comes from /etc/environment by way of PAM, and it is a perfectly normal system PATH.
The useful lesson survives the correction, and it is sharper than the folklore: cron gives you a system PATH, never your PATH. Compare the two on the same machine:
$ echo $PATH # interactive shell
/bin:/home/peter/.local/bin:/usr/local/cuda/bin:/usr/local/sbin:/usr/local/bin:...
# plus a version manager, plus node_modules/.bin, plus more
Everything your .bashrc or .profile added is gone: ~/.local/bin, anything installed by nvm, rbenv or pyenv, and any per-project directory. A job that calls node or composer by bare name works in your terminal and fails under cron for exactly this reason.
SHELL is /bin/sh, not bash. On Debian and Ubuntu /bin/sh is dash, which is a smaller, faster, stricter shell. Anything bash-specific fails: [[ ... ]], arrays, source instead of ., ${var,,}. The failure is often a single confusing line in a log you are not reading.
What is missing matters too. There is no SSH_AUTH_SOCK, so a job that pushes to git over SSH cannot use your agent. There is no XDG_RUNTIME_DIR, so systemctl --user and rootless container tools misbehave. There is no DISPLAY, so nothing graphical works.
There are three ways to deal with this, in increasing order of robustness:
# 1. Set what you need at the top of the crontab
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin:/home/peter/.local/bin
# 2. Use absolute paths everywhere, always
30 3 * * * /usr/bin/php /var/www/site/cli/joomla.php scheduler:run --all
# 3. Best: call a script, and let the script set up its own world
30 3 * * * /usr/local/bin/nightly.sh
The third option is the one to reach for. A one-line crontab entry that calls a script keeps the schedule and the work separate, puts the logic in version control, and lets you run the exact same thing by hand while you are testing.
6.2 The Working Directory Is Not Where Your Script Lives
The measurement above contains one line that causes more failures than it looks like it should:
PWD=/home/peter
cron starts your job in the home directory of the user it runs as. Not in the directory the script lives in, and not in the directory you happened to be standing in when you wrote the crontab entry. For jobs in /etc/cron.d it is the home directory of the user named in the sixth field, which for www-data on Debian is /var/www. For the run-parts directories it is /, because /etc/crontab says cd / && before every one of them.
Any script that reaches for a file relative to itself breaks:
require 'config.php'; # PHP, resolved against the working directory
. ./settings.sh # shell
source venv/bin/activate # python virtualenv
cat ../data/input.csv # anything at all
All four work when you run the script from its own folder and fail from cron, with an error that goes to a mail nobody reads. There are two good fixes and one bad one:
# Acceptable: change directory in the crontab entry
0 3 * * * cd /var/www/site && /usr/bin/php cli/import.php
# Better: let the script find itself, so it works from anywhere
#!/bin/bash
cd "$(dirname "$0")" || exit 1
# Best: never use relative paths inside a scheduled script at all
The middle one is worth adopting as a habit. Two lines at the top of every script you schedule removes the entire class of problem, and it also means you can run the script by hand from your own home directory while testing.
6.3 Debugging a Job That Does Not Run
"My cron job does not work" is really six different questions, and answering them in order finds the problem far faster than guessing. Work down this list and stop at the first step that fails.
1. Is cron running your crontab at all? Before anything else, prove the plumbing works. Install a job that cannot fail:
* * * * * /usr/bin/date >> /tmp/cron-test.log 2>&1
Wait a minute. If /tmp/cron-test.log does not appear, the problem is not your script: it is the daemon, the crontab, or permission to use cron at all. Check systemctl is-active cron and section 6.6.
2. Did cron try? The log tells you what cron started, and it prints the command exactly as cron parsed it. This is where a percent sign shows up as a truncated command:
$ journalctl -t CRON --since '-10 min'
3. Does the command work by hand? Run the exact text from the crontab in your own shell. If it fails here, cron was never the problem.
4. Does it work as the right user? A job under www-data has different permissions, a different home directory, and possibly no shell. Test as that user:
$ sudo -u www-data /usr/bin/php /var/www/site/cli/joomla.php scheduler:run --all
This one step finds most permission problems immediately, and it is the step people skip.
5. Does it work in cron's environment? This is where the PATH and shell differences from 6.1 surface. Strip your environment down and try again:
$ env -i /bin/sh -c '/usr/local/bin/job.sh'
For the exact truth rather than an approximation, schedule /usr/bin/env for one minute and read what comes back, as in 6.1.
6. Is the output being thrown away? If the job runs and nothing happens, add a redirect and look at what it says. A wrapper that records the surroundings alongside the output turns an invisible failure into an obvious one:
#!/bin/bash
LOG=/var/log/myjob.log
{
echo "===== $(date --iso-8601=seconds) ====="
echo "user: $(id -un) pwd: $(pwd)"
echo "PATH=$PATH"
/usr/bin/php /var/www/site/cli/import.php
echo "exit: $?"
} >> "$LOG" 2>&1
One more check applies only to the cron.daily family: if the script is in one of those directories, run run-parts --test on it before anything else. A filename problem makes every step above look fine while nothing ever executes. Section 7.3 has the details.
6.4 Stopping Jobs From Overlapping
cron will happily start a job while the previous run is still going. A backup that normally takes four minutes and is scheduled every five minutes will, on a bad day, run twice at once. Two copies writing the same file is how backups get corrupted.
cron has no option for this, but flock solves it in one word:
*/5 * * * * flock -n /tmp/backup.lock /usr/local/bin/backup.sh
The -n flag (short for nonblock) means: if the lock is already held, give up immediately rather than waiting. The second run exits silently and the first keeps going. Two variations are worth knowing:
| Option | Behaviour when the lock is held |
|---|---|
-n |
Exit at once. Right for frequent jobs where skipping a run is fine. |
-w 30 |
Wait up to 30 seconds for the lock, then give up. |
-E 0 |
Use exit code 0 when the lock is busy, so monitoring does not report a failure. |
Put the lock file somewhere that survives, such as /var/lock, if the job matters. Locking is one line, and it prevents a class of failure that is genuinely difficult to debug afterwards.
Overlap is not the only way a scheduled job hurts the machine it runs on. A nightly report that pins every core, or a backup that saturates the disk, will slow down the website that is the reason the server exists. Two wrappers cost nothing:
0 2 * * * nice -n 10 ionice -c 3 /usr/local/bin/report-generator
nice -n 10 lowers the CPU priority, so anything interactive wins. ionice -c 3 puts the job in the idle I/O class, where it only gets disk access when nothing else wants it. Neither makes the job slower on an idle machine; both make it invisible on a busy one.
6.5 anacron, for Machines That Are Not Always On
cron has no memory. A laptop that is closed at 06:25 never runs the daily jobs, and it never catches up. anacron exists for exactly this, and its own manual page states the difference plainly: it does not assume the machine is running continuously, and it measures periods in days rather than clock times.
Its table has four columns instead of five fields:
$ cat /etc/anacrontab
SHELL=/bin/sh
HOME=/root
LOGNAME=root
# period(days) delay(min) job-identifier command
1 5 cron.daily run-parts --report /etc/cron.daily
7 10 cron.weekly run-parts --report /etc/cron.weekly
@monthly 15 cron.monthly run-parts --report /etc/cron.monthly
Read the first line as: "run this at most once a day; when you do start it, wait five minutes first". anacron records a timestamp per job identifier after each successful run, and on the next start it compares the date. If the last run was yesterday or earlier, the job is due. The delay staggers jobs so that a machine waking up does not run everything at once.
Only the date is compared, never the hour. anacron is the right tool for "roughly daily" work and the wrong tool for anything that must happen at a specific time.
This also explains those test -x /usr/sbin/anacron || guards in /etc/crontab. If anacron is installed, cron steps back from the daily, weekly and monthly directories and lets anacron own them, so the two never both run the same script.
6.6 Who Is Allowed to Use cron
Two files control access, and the logic has one surprise:
| Situation | Who may use crontab |
|---|---|
/etc/cron.allow exists |
Only users listed in it. cron.deny is ignored completely. |
Only /etc/cron.deny exists |
Everyone except the users listed in it. |
| Neither exists | Site-dependent. On standard Debian and Ubuntu systems, all users may. |
root can always install a crontab regardless of either file. On a shared or hosting server, an empty /etc/cron.allow listing only the accounts that need it is a cheap and effective restriction, because it denies everyone else by default rather than requiring you to name them.
One permission detail bites people: both files must be world-readable, or readable by the crontab group. If they are not, cron denies access to every user until the permissions are fixed.
6.7 Keeping a Scheduled Job Secure
A cron job is an unattended command that runs as somebody, often as root, forever. That deserves the same care as any other privileged automation, and two specific mistakes come up again and again.
A command line is public. Anything you put in a crontab is visible in the process list while it runs, to every user on the machine:
$ ps -eo user,args | grep curl
root curl -u admin:supersecret https://api.example.com/backup
Unless the system is configured with hidepid, which is not the default, any local account can watch for that. The same secret also ends up in the crontab file, in your backups of that file, and in any log that records the command. Keep credentials in a file the job reads instead:
$ sudo install -o root -g root -m 600 /dev/null /etc/myapp/backup.env
$ sudoedit /etc/myapp/backup.env # API_TOKEN=...
# and in the script, not in the crontab:
. /etc/myapp/backup.env
curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com/backup
A root job is only as safe as the file it runs. This line looks fine:
0 3 * * * root /usr/local/sbin/backup.sh
If any unprivileged user can write to backup.sh, that user can put anything they like in it and root will run it at three in the morning. That is not a backup script any more, it is a root shell on a timer. The same applies to the directory containing it, because being able to replace a file is as good as being able to edit it.
$ ls -ld /usr/local/sbin /usr/local/sbin/backup.sh
drwxr-xr-x 2 root root 4096 Aug 23 12:00 /usr/local/sbin
-rwxr-xr-x 1 root root 842 Aug 23 12:00 /usr/local/sbin/backup.sh
Both should be owned by root and writable by nobody else. cron already enforces this for the crontab files themselves, which is why /etc/cron.d entries are ignored unless they are root-owned and not group- or world-writable, but nothing checks the script at the other end of the line. That check is yours.
The third rule needs no example: do not run a job as root because it is easier. Most scheduled work needs one directory and one database, so give it an account with exactly that.
6.8 Driving a Web Application's Scheduler
Most modern web applications have their own task scheduler and expect a single cron entry to drive it. Joomla is a good example, because it ships a console application for exactly this. The command names come straight from the installed source:
$ php /var/www/site/cli/joomla.php scheduler:list
$ php /var/www/site/cli/joomla.php scheduler:run --all
$ php /var/www/site/cli/joomla.php scheduler:run --id 3
The crontab entry that drives it needs three things that are easy to get wrong:
*/5 * * * * www-data /usr/bin/php /var/www/site/cli/joomla.php scheduler:run --all >> /var/log/joomla-cron.log 2>&1
- Run as the web server user. If you run it as root, every cache file and log the application writes ends up owned by root, and the website itself can no longer write to them. This is a very common way to break a site with a cron job that "works".
- Use the absolute path to PHP. There is often more than one PHP on a server, and the CLI version is not always the one the web server uses.
which phpin your shell may not be what cron finds. - Log the output. The scheduler reports what it ran. Without a redirect that report is mailed into a void.
The same pattern applies to any application with a scheduler: run it as the application's user, with an absolute interpreter path, and send the output to a file. The application decides which tasks are due; cron only supplies a heartbeat.
The second point deserves more than one line, because "PHP is PHP" is not true. The PHP that cron runs is the CLI build, and on Debian and Ubuntu it has its own configuration, entirely separate from the one your website uses:
$ php --ini
Configuration File (php.ini) Path: /etc/php/8.3/cli
Loaded Configuration File: /etc/php/8.3/cli/php.ini
Scan for additional .ini files in: /etc/php/8.3/cli/conf.d
Read the path. That cli component is a directory name, and there is a sibling fpm directory next to it holding a different php.ini and a different set of enabled extensions. Four things follow, and each one has caused a support ticket:
- Different limits. CLI usually ships
memory_limit = -1and no execution time limit, while FPM has real ones. A task that dies in the browser may run happily from cron, and a task tested from cron may die in the browser. - Different extensions. An extension enabled for FPM is not automatically enabled for CLI. The job fails with "class not found" for something the website clearly has.
- Different versions. On a server with several PHP versions installed,
/usr/bin/phpis whichever one is the default alternative, and the site may be running a different one under FPM. - Different everything, in a container. If the site runs in Docker, the host's
/usr/bin/phpis not the site's PHP at all, and the cron entry has to go through the container.
Three commands settle it before you schedule anything:
$ /usr/bin/php -v # which version cron will actually use
$ /usr/bin/php --ini # which configuration it loads
$ /usr/bin/php -m # which extensions are enabled for CLI
On a server with more than one version, name the version explicitly in the crontab (/usr/bin/php8.3) rather than trusting the default to stay where it is through the next upgrade.
6.9 Knowing That It Actually Worked
cron starts a command and forgets it. It never looks at the exit code, so from cron's point of view a backup that wrote nothing and a backup that wrote fifty gigabytes are identical events. If you want to know which one happened, the job has to say so.
Start by making the script honest. A shell script returns the status of its last command, which is rarely what you mean:
#!/bin/bash
set -euo pipefail # stop on the first error, and on a failure inside a pipe
Without pipefail, mysqldump ... | gzip > out.gz reports success whenever gzip succeeds, which it does even when it is handed nothing at all.
Then leave a mark that something outside can check. The pattern is one line, and the && is the whole trick:
30 3 * * * /usr/local/bin/backup && touch /var/lib/myapp/backup.last-success
The timestamp only moves when the job succeeded. Now "did the backup run last night?" becomes a question anyone can answer, including a monitoring system that has no idea what a backup is:
$ find /var/lib/myapp/backup.last-success -mmin +1500
/var/lib/myapp/backup.last-success # printed = older than 25 hours = alert
This is stronger than checking whether cron is running, and much stronger than checking whether the job appears in the log, because it reports on the work rather than on the attempt. Useful signals, roughly in order of how much they tell you:
| Signal | Answers |
|---|---|
The cron log has a CMD line |
cron tried. Nothing more. |
| Exit status was 0 | The command believes it worked |
| Age of a success marker | It last worked at a known time |
| Size and age of the output file | It produced something plausible |
| A count the job reports (rows, files, bytes) | It did the amount of work you expect |
For anything that matters, push rather than pull: have the job call a monitoring endpoint on success, and let that service raise the alarm when the call does not arrive. A job that fails silently and a job that was deleted six weeks ago look the same from the server. They do not look the same to something waiting for a heartbeat.
6.10 systemd Timers, the Modern Alternative
On any systemd machine there is a second scheduler already running, and a surprising amount of the work you assume cron does has quietly moved to it:
$ systemctl list-timers --all
NEXT LEFT LAST UNIT
Sun 2026-08-23 15:39:00 CEST 4min 38s Sun 2026-08-23 15:09:04 CEST phpsessionclean.timer
Sun 2026-08-23 16:34:21 CEST 59min Sun 2026-08-23 15:31:08 CEST anacron.timer
Mon 2026-08-24 00:00:00 CEST 8h Sun 2026-08-23 00:00:05 CEST logrotate.timer
Mon 2026-08-24 01:47:50 CEST 10h Sun 2026-08-23 05:28:44 CEST man-db.timer
A timer is two files: a .timer that says when, and a .service that says what. Here is the one that replaced the PHP session cleanup from section 5.2, which is why that cron entry has a systemd test around it:
$ systemctl cat phpsessionclean.timer
[Unit]
Description=Clean PHP session files every 30 mins
[Timer]
OnCalendar=*-*-* *:09,39:00
Persistent=true
[Install]
WantedBy=timers.target
That OnCalendar line is the direct equivalent of 09,39 * * * *. The syntax reads year-month-day hour:minute:second, and it supports the same lists and steps that cron does. Unlike cron, you can ask whether you got it right before trusting it:
$ systemd-analyze calendar "*-*-* *:09,39:00"
Normalized form: *-*-* *:09,39:00
Next elapse: Sun 2026-08-23 15:39:00 CEST
(in UTC): Sun 2026-08-23 13:39:00 UTC
From now: 2min 15s left
There is no cron command that does this, and it alone is a reason to reach for a timer when the schedule is complicated.
| Need | cron | systemd timer |
|---|---|---|
| Set it up quickly | One line, one command | Two files plus systemctl enable --now |
| Catch up after downtime | No. anacron, separately. | Persistent=true |
| Avoid a thundering herd | Pick odd minutes by hand | RandomizedDelaySec=5m |
| Wait for the network or a database | No. @reboot guesses. |
After=, Requires= |
| Where the output goes | Mail, usually nowhere | The journal, always |
| Prevent overlapping runs | flock |
Built in: a service is either running or not |
| Test it right now | Wait, or change the time | systemctl start unit.service |
| Limit CPU, memory or IO | No | CPUQuota=, MemoryMax= |
| Works everywhere | Yes, including containers and BSD | Only where systemd runs |
The honest summary: for "run this script every night", cron is one line and there is no reason to change. For anything that must survive downtime, wait for another service, be resource-limited, or be tested before it goes live, a timer is the better tool and its logging alone repays the extra file.
Back to top7. Something Most Users Do Not Know
7.1 A Percent Sign Is a Newline
This is the strangest rule in the whole format, and it is the one that produces the most baffling failures. In a crontab, an unescaped % is not a percent sign. The first one ends the command, and everything after it becomes standard input for that command. Any further ones become newlines.
Here is the proof. This crontab line was installed on a live machine:
* * * * * cat > /tmp/cron-pct.txt%hello from stdin%second line
Watch what cron logged when it ran. The command it executed is not the command that was written:
$ journalctl -t CRON
Aug 23 15:35:01 xps CRON[408132]: (peter) CMD (cat > /tmp/cron-pct.txt)
$ cat /tmp/cron-pct.txt
hello from stdin
second line
The command was cut at the first %, and the rest was fed to it as two lines of input. That is documented behaviour, not a bug, and it is why this line does not do what it looks like:
0 9 * * * /usr/local/bin/report.sh --format=csv --sample=50% # BROKEN
0 9 * * * /usr/local/bin/report.sh --format=csv --sample=50\% # correct
The bite is worst with date, because date format strings are made of percent signs. A daily log file named by date is a natural thing to want, and the naive version fails:
0 3 * * * tar czf /backup/$(date +%Y-%m-%d).tgz /var/www # BROKEN
0 3 * * * tar czf /backup/$(date +\%Y-\%m-\%d).tgz /var/www # correct
Escaping every one of them is easy to get wrong. Putting the command in a script instead means you never have to think about it again, which is the real argument for section 6.1's third option.
The feature does have a use. Because everything after the first % is standard input, you can feed data to a command without a here-document:
0 9 * * 1 mail -s "Monday reminder" This email address is being protected from spambots. You need JavaScript enabled to view it. %Stand-up at 10:00.
7.2 Day of Month and Day of Week Are OR, Not AND
Every other field in a crontab narrows the schedule. These two do the opposite. From crontab(5):
If both fields are restricted (i.e., aren't *), the command will be run when either field matches the current time.
So this line does not mean "the 1st and 15th, but only if it is a Friday":
30 4 1,15 * 5 /usr/local/bin/job.sh
It runs at 04:30 on the 1st, on the 15th, and on every Friday. On a normal month that is roughly six runs instead of the one or two you expected.
The rule only applies when both fields are restricted. If either is *, the other one behaves normally. So the safe habit is simple: never restrict both day fields in the same line. When you genuinely need the intersection, leave one field as * and put the test in the command. The manual page gives the pattern for "the second Saturday of the month":
0 4 8-14 * * test $(date +\%u) -eq 6 && /usr/local/bin/job.sh
Days 8 to 14 contain exactly one of each weekday, so the date range picks the second week and date +%u (day of week, 1 to 7) picks Saturday. Note the escaped \%, for the reason in 7.1.
7.3 A Dot in the Filename Means It Never Runs
Drop a script called backup.sh into /etc/cron.daily, make it executable, and it will never run. No error, no log line, nothing.
The reason is run-parts, and it is documented: unless it is given --lsbsysinit or --regex, "the names must consist entirely of ASCII upper- and lower-case letters, ASCII digits, ASCII underscores, and ASCII minus-hyphens". A dot is not in that list, so the file is silently ignored along with any directory.
It takes ten seconds to demonstrate:
$ mkdir /tmp/rptest
$ touch /tmp/rptest/good /tmp/rptest/bad.sh
$ chmod +x /tmp/rptest/*
$ run-parts --test /tmp/rptest
/tmp/rptest/good
Both files exist, both are executable, and only one of them would ever run. This rule is a deliberate feature: it is what stops logrotate.dpkg-old and editor backup files from being executed during a package upgrade. But it catches everybody once, because naming a shell script .sh is the most natural thing in the world.
The same naming rule applies to /etc/cron.d. Get in the habit of running run-parts --test on the directory after you add anything.
7.4 Steps Restart at the Top of the Range
*/5 works perfectly because 5 divides 60. */7 does not, and the result is not what most people expect. A step generates values inside its field's range and then the field starts over:
*/7 * * * * runs at minute 0, 7, 14, 21, 28, 35, 42, 49, 56
then the hour ends and the next run is at minute 0
The gap between 56 and the next 0 is four minutes, not seven. Over a day you get 9 runs an hour rather than a run every seven minutes. The same applies in every field: */9 in the hour field runs at 0, 9 and 18, and then the day restarts, so the gap from 18:00 to the next 00:00 is six hours.
If a job genuinely needs an even interval, either pick a step that divides the field (2, 3, 4, 5, 6, 10, 12, 15, 20, 30 for minutes) or use a systemd timer with OnUnitActiveSec=7m, which measures from the end of the last run and does not care about clock boundaries at all.
7.5 One Clock for Everybody
cron runs in a single timezone, the system's, and this cannot be changed per user. The manual is blunt about it, and it is a limitation people discover the hard way after a server migration:
It currently does not support per-user timezones. Even if a user specifies the TZ environment variable in his crontab this will affect only the commands executed in the crontab, not the execution of the crontab tasks themselves.
So TZ=Europe/Amsterdam at the top of your crontab changes what date prints inside your job. It does not move the job. If a report must go out at 09:00 Amsterdam time on a server running UTC, you either write the schedule in UTC and update it twice a year, or you let the job start early and check the local time itself.
Daylight saving is handled, and the rules are specific. For a clock change of less than three hours:
- When the clock jumps forward, jobs that would have run in the skipped hour are run shortly after the change. Nothing is lost.
- When the clock goes backward, jobs in the repeated hour are not run a second time. Nothing is doubled.
- This applies only to jobs at a fixed time. Jobs with a wildcard in the hour or minute field, and
@hourly, simply follow the new time. - A change of more than three hours is treated as someone correcting the clock, and the new time takes effect immediately.
That is better behaviour than most people assume, but it only covers cron's own scheduling. A job that computes "yesterday" for itself still has to handle the 23-hour and 25-hour days on its own.
7.6 Knowing Where cron Stops
Part of expertise is knowing when a tool is the wrong one. cron does one thing: it starts a command at a time. Everything below is something it deliberately does not do.
| Need | Use | Why |
|---|---|---|
| Run once, at a future time | at |
cron is for repetition. at is for "tomorrow at 6". |
| Catch up after downtime | anacron or Persistent=true |
cron has no memory of missed runs |
| Wait for a service to be ready | A systemd unit | cron has no concept of dependencies |
| Retry a failed job | The script, or a real scheduler | cron never looks at the exit code |
| Chain jobs in order | One script, or a workflow tool | Scheduling two jobs ten minutes apart and hoping is not a dependency |
| Know that a job stopped running | External monitoring | Silence is cron's normal output, so silence cannot be your alarm |
The last row is the one worth taking seriously. Every other failure on this list announces itself eventually. A cron job that quietly stopped six weeks ago looks exactly like a cron job that is working, from the outside, until the day you need what it was producing.
Back to top8. Best Practices
- Put the work in a script, not in the crontab. One line that calls
/usr/local/bin/nightly.shkeeps the schedule separate from the logic, puts the logic in version control, lets you test by running it yourself, and means you never have to escape a percent sign. - Always redirect the output. End every line with
>> /var/log/thing.log 2>&1, in that order. Without it, the only report of a failure is a mail that probably goes nowhere. - Use absolute paths for everything. Both the interpreter and the script. cron's PATH is a system PATH, not yours, and nothing your shell profile added is present.
- Test the command in cron's environment, not yours.
env -i /bin/sh -c '/usr/local/bin/job.sh'gets you close, and a temporary* * * * *entry runningenvgets you the exact truth. - Back up before you edit.
crontab -l > ~/crontab.backuptakes a second, and-rsits next to-eon the keyboard with no confirmation and no undo. - Check the syntax before you install.
crontab -n fileparses without changing anything, which is what you want inside a deployment script. - Never restrict both day fields on the same line. They are combined with OR, not AND. Leave one as
*and put the extra condition in the command. - Make every scheduled script find its own directory. cron starts you in the user's home directory, not next to the script.
cd "$(dirname "$0")"at the top removes a whole class of "works by hand, fails from cron". - Keep secrets out of the crontab. A command line is visible to every user on the machine through
ps. Put credentials in a file mode 0600 and read it from the script. - Check who can write the script a root job runs. If an unprivileged user can edit it, or edit the directory holding it, they own root. cron checks the crontab file's permissions and nothing else.
- Rotate whatever you log to. A file appended to every five minutes fills a disk eventually. A stanza in
/etc/logrotate.d, or a pipe intologger, takes one minute. - Leave a success marker, and alert on its age.
job && touch /var/lib/app/last-successturns "did it work?" into a question a monitoring system can answer without knowing anything about the job. - Be a good neighbour on a busy machine.
nice -n 10 ionice -c 3in front of a heavy job keeps it from competing with the website the server exists for. - Add
flock -nto anything that might run long. One word prevents two copies of a backup writing the same file. - Prefer
/etc/cron.dfor server jobs. Ordinary files that configuration management can deploy, with an explicit user field, and no dot in the filename. - Run application jobs as the application's user. A scheduler run as root leaves root-owned cache files behind and breaks the site it was meant to maintain.
- Monitor from outside. Have the job report success to a monitoring service when it finishes. cron's silence is identical whether the job worked, failed, or has not run since March.
$ man 5 crontab # the file format: fields, steps, the OR rule, the % rule
$ man 1 crontab # the command: -l, -e, -r, -n, -i
$ man 8 cron # the daemon: logging, DST, Debian specifics
$ man 8 anacron # for machines that are not always on
$ man 8 run-parts # the filename rules for cron.daily and friends
$ man 7 systemd.time # OnCalendar syntax, if you move to timers
Back to top9. Common Mistakes
9.1 Myth Versus Reality
| Myth | Reality |
|---|---|
"cron's PATH is /usr/bin:/bin." |
Not on current Ubuntu, which runs cron -P and inherits a full system PATH. The real rule is that it is never your PATH. |
"30 4 1,15 * 5 runs on the 1st and 15th if it is a Friday." |
It runs on the 1st, the 15th, and every Friday. Both day fields are OR. |
"My script is executable, so /etc/cron.daily will run it." |
Not if the name contains a dot. run-parts ignores it silently. |
"*/7 means every seven minutes." |
It means minutes 0, 7, ... 56, then the hour restarts. The last gap is four minutes. |
| "The job printed nothing, so it worked." | It printed nothing that reached you. With no mail system, cron discards the error. |
"TZ= in my crontab moves the schedule." |
It changes the environment of the command only. cron schedules in the system timezone, always. |
| "I need to restart cron after editing a crontab." | No. cron checks the spool and /etc/cron.d for changes every minute. |
"A CMD line in the log proves the job worked." |
By default cron logs only that a job started. Add -L 15 to log endings and failures. |
"@reboot runs after the system is up." |
It runs when the cron daemon starts, which may be before the network or the database. |
| "cron runs my script from the directory it lives in." | It runs from the home directory of the user. Relative paths inside the script break. |
| "The PHP that cron uses is the one my website uses." | It is the CLI build, with its own php.ini, its own extensions, and possibly a different version. |
| "A password in a crontab is private because the file is mode 600." | The file is. The command line is not: ps shows it to every user while the job runs. |
"Set options in /etc/default/cron." |
Deprecated on current Ubuntu. The file itself tells you to use systemctl edit cron.service. |
9.2 Other Traps to Avoid
- Forgetting the user field in
/etc/cron.d. The first word of your command gets read as a user name and the line fails. User crontabs have five fields before the command; system files have six. - Editing
/var/spool/cron/crontabsdirectly. The directory is writable only by thecrontabgroup for a reason, and a file placed there by hand may never be loaded. Usecrontab -eorcrontab file. - Leaving off the final newline. cron treats a crontab whose last line has no newline as broken, and
crontabrefuses to install it. Any decent editor adds one; a shell heredoc might not. - Writing
2>&1 >> file. The order is wrong, and error output still goes nowhere. Redirect standard output first, then point standard error at it. - Assuming bash. cron gives you
/bin/sh, which on Debian and Ubuntu is dash.[[ ]], arrays andsourceall fail. SetSHELL=/bin/bashat the top of the crontab, or write portable code. - Scheduling everything at midnight.
@dailyputs every job on the machine at 00:00 together. Spread them out, and let a timer'sRandomizedDelaySecdo it for you if the machine is one of many. - Testing by waiting. Do not edit the schedule to "two minutes from now" and stare at the terminal. Run the command by hand first, then install the real schedule and verify with the log.
- Renaming a user account with a crontab. Spool files are named after the account. Rename the user and the crontab stops running, with nothing to indicate why.
- Letting a log file grow without limit. A job that appends every five minutes and is never rotated will fill the disk, and a full disk breaks everything else on the server before anyone looks at the cron job.
- Trusting an exit code through a pipe.
dump | gzip > out.gzreports success whenevergzipsucceeds, even on empty input. Useset -o pipefailin the script. - Believing a job that is guarded off. Package cron entries increasingly contain
if [ ! -d /run/systemd/system ], which makes them do nothing on a systemd machine. Check for a timer before you debug the cron entry.
10. Summary
cron is a small, old, extremely reliable program that does exactly one thing, and most of the trouble people have with it comes from expecting it to do more.
- cron wakes once a minute, compares the clock to a list of rules, and runs whatever matches. It has no queue, no memory, and no interest in whether a job succeeded.
- Jobs live in three kinds of place: user crontabs (five fields),
/etc/crontaband/etc/cron.d(five fields plus a user), and thecron.dailystyle directories (scripts, no schedule). - The five fields are minute, hour, day of month, month, day of week. Both 0 and 7 mean Sunday.
- Ranges, lists and steps combine freely, but a step restarts at the top of its field, so
*/7is not an even interval. - If both day fields are restricted, they are combined with OR. Never restrict both.
- An unescaped
%ends the command and becomes standard input. Escape it as\%, or put the command in a script. - Output is mailed, and on most servers that mail goes nowhere. Redirect with
>> file 2>&1or you will never learn that a job failed. - The environment is six variables. The PATH is a system PATH, not yours, and the shell is
/bin/sh, not bash. - In
cron.dailyandcron.d, a filename containing a dot is silently ignored. Check withrun-parts --test. - Jobs start in the user's home directory, not next to the script, so relative paths fail.
- cron never looks at the exit code. If you want to know that a job worked, leave a success marker and alert on its age.
- A crontab command line is visible to every user through
ps, and a root job that runs a user-writable script is a root shell on a timer. - cron uses one timezone for everyone, and handles daylight saving sensibly for fixed-time jobs.
flock -nstops runs from overlapping.anacroncovers machines that are switched off.- systemd timers do catch-up, dependencies, randomised delays, resource limits and journal logging. For anything beyond "run this nightly", they are the better tool.
This is the quick reference worth keeping:
# .---------------- minute (0 - 59)
# | .------------- hour (0 - 23)
# | | .---------- day of month (1 - 31)
# | | | .------- month (1 - 12) or jan,feb,...
# | | | | .---- day of week (0 - 7) (0 and 7 are Sunday) or sun,mon,...
# * * * * * command
crontab -l list your jobs
crontab -e edit them
crontab -l > backup.txt back them up FIRST
crontab -n file check syntax, install nothing
crontab file install from a file (version control)
crontab -i -r delete, with a confirmation prompt
crontab -u www-data -l another user's jobs (root only)
*/5 * * * * every 5 minutes
15 */6 * * * 00:15, 06:15, 12:15, 18:15
30 3 * * * 03:30 every day
0 4 * * 1 Mondays at 04:00
0 2 1 * * 1st of the month at 02:00
@reboot when the cron daemon starts
@daily @weekly @monthly midnight shorthands (all at 00:00)
... >> /var/log/job.log 2>&1 keep the output (order matters)
... > /dev/null 2>&1 throw it away on purpose
MAILTO="" no mail for any job in this file
flock -n /var/lock/j.lock ... do not overlap with the previous run
nice -n 10 ionice -c 3 ... stay out of the way on a busy machine
... 2>&1 | logger -t myjob log to syslog instead of a file you must rotate
job && touch /var/lib/ok success marker, for monitoring to check the age of
\% a literal percent sign
journalctl -t CRON what cron started
run-parts --test /etc/cron.d what would run from a directory
sudo -u www-data CMD test as the user the job will run as
env -i /bin/sh -c 'CMD' test in something close to cron's environment
php --ini which php.ini the CLI build loads
ps -eo user,args proof that command lines are not private
systemctl edit cron.service add -L 15 to log endings and failures
systemd-analyze calendar "..." test a systemd timer schedule
A scheduled job is one of the few parts of a server that nobody looks at until the day it matters. If you want the backups, renewals and cleanups on your server set up so that they are visible when they work and noisy when they fail, rather than silent either way, that is exactly the kind of quiet work I enjoy helping with.
Back to top

Peter is a Joomla specialist and a Linux admin for fast, secure and scalable websites.












