Set up a cron job: schedule, permissions and the most common mistakes

Published on 20 min read

The job runs in your shell but not in cron. This guide explains the five time fields, the difference between the user crontab and /etc/cron.d, the PATH trap and how to prove that a job really succeeded.

A cron job takes five minutes to set up and often costs hours afterwards. The command runs perfectly in your shell, nothing happens when cron runs it, and the log tells you at best that cron started something. This article covers exactly the places where the usual guides stop: the real error messages, the differences between the distributions, and the question of how you can tell that a job actually ran to the end instead of merely starting.

Everything here refers to Debian 13 (trixie), Debian 12 (bookworm), Ubuntu 24.04 LTS and Ubuntu 22.04 LTS. All four use the same cron (the Debian variant of Vixie Cron), not cronie as on Red Hat and Fedora. That difference becomes decisive later on, when we get to the time zone.

Is the cron service running at all?

Full installations ship cron. Minimal cloud images, container base images and slim netinst installations frequently leave the package out. That is the first thing to settle before you start debugging anything else.

command -v crontab
systemctl status cron

Deliberately command -v crontab and not command -v cron: the daemon itself lives in /usr/sbin, and on Debian that directory is only in root's search path. As a normal user you simply get no output there, even though cron is installed. On Ubuntu 24.04 the query works for unprivileged users as well. crontab, by contrast, lives in /usr/bin everywhere and is therefore visible to everyone, and systemctl status cron answers the more important question anyway, namely whether the service is actually running.

If the service is missing, install it:

apt-get update
apt-get install -y cron
systemctl enable --now cron

On Debian and Ubuntu the service is called cron, not crond. Anyone used to Red Hat gets a Unit crond.service could not be found. here and goes looking in the wrong place.

In normal operation there is no need to restart anything: the Debian cron watches the crontab directories through inotify and picks up changes by itself. So you do not have to restart the service after editing a crontab. The exceptions are a change of the system time zone and changes to /etc/default/cron.

The five fields, and the trap in the fifth

Every line starts with five time fields, followed by the command.

FieldRangeNote
Minute0 to 59
Hour0 to 2324-hour format, no time zone
Day of month1 to 31
Month1 to 12jan to dec work too
Day of week0 to 70 and 7 are both Sunday, sun to sat work too
30 4 * * *      /usr/local/bin/backup.sh      # daily at 04:30
*/10 * * * *    /usr/local/bin/check.sh       # every 10 minutes
0 2 * * 0       /usr/local/bin/weekly.sh      # Sundays at 02:00
15 3 1 * *      /usr/local/bin/monthly.sh     # on the 1st at 03:15
0 9-17 * * 1-5  /usr/local/bin/business.sh    # hourly on weekdays, 9 to 17

Two details are almost always misunderstood.

Day of month and day of week are an OR, not an AND. As soon as both fields are restricted, meaning neither of them holds an asterisk, the job runs whenever one of the two matches. 0 3 13 * 5 does not mean "Friday the 13th", it means "every 13th plus every Friday". If you really want Friday the 13th, check for it inside the script.

Step values do not divide evenly. */7 * * * * runs at minutes 0, 7, 14, 21, 28, 35, 42, 49 and 56, then the counter jumps to the next hour. So only four minutes pass between 56 and 0. This applies to every interval that does not divide cleanly into 60 or 24. There is no clean way to write "every 90 minutes" in cron, and a systemd timer is the better choice here.

Using crontab -e properly

Never edit the user crontab directly in /var/spool/cron/crontabs/, always go through the tool:

crontab -e

On the first call the program reports no crontab for root - using an empty one. On freshly installed systems an editor selection follows. If no editor is present at all, in a slim image for instance, the call aborts with a message such as /usr/bin/sensible-editor: 25: editor: not found. The remedy: install an editor, or set the editor you want explicitly.

apt-get install -y nano
EDITOR=nano crontab -e

The big advantage of crontab -e over writing the file directly is the validation on save. If a line is broken, you see:

"/tmp/crontab.7hK2mn/crontab":3: bad minute
errors in crontab file, can't install.
Do you want to retry the same edit? (y/n)

The line number is correct, the field named in the message is not always: bad minute also shows up when a field is simply missing, because the parser then reads everything shifted to the left. On success the operation ends with crontab: installing new crontab. Only that line means the change was actually installed.

Other commands worth knowing:

crontab -l                         # show
crontab -l > /root/crontab.bak     # back up
crontab -u www-data -l             # read another user's crontab (as root)
crontab -i -r                      # delete, with a prompt (terminal only)

One note on backups: crontab -l > /root/crontab.bak reports no crontab for root and returns exit code 1 as long as no crontab exists for that user yet. The target file is created anyway, with a size of 0 bytes. Following this article step by step you would not notice, but a script that checks the return value or overwrites an older backup certainly will.

crontab -r without -i deletes the entire crontab immediately and without asking. Since r and e sit close together on the keyboard, this is a real data loss scenario. So make crontab -i -r your habit at the terminal, and back up first with crontab -l.

The key word here is terminal. crontab -i -r is a purely interactive command. If standard input is not attached to a terminal, so inside a script, inside a cron job or in a one-liner like ssh host "crontab -i -r", the prompt loops forever: crontab: really delete root's crontab? (y/n) Please enter Y or N: Please enter Y or N: ... repeats without limit and writes hundreds of kilobytes of output within seconds, and the call can only be ended by killing it from outside. For anything automated, take the non-interactive variant and back up beforehand:

crontab -l > /root/crontab.bak     # back up before deleting
crontab -r                         # delete without a prompt
printf "y\n" | crontab -i -r       # keep the prompt, supply the answer

If /etc/cron.allow or /etc/cron.deny exist, they decide who is allowed to create crontabs at all. Affected users get: You (username) are not allowed to use this program (crontab). If /etc/cron.allow is present it applies exclusively, and every user not listed in it is locked out.

User crontab, /etc/crontab and /etc/cron.d

There are four places where schedules can live, and they use different formats. This is exactly where the mistakes that are hardest to find come from.

User crontab: five fields

Maintained through crontab -e, it runs as the user it belongs to and has no user field. If you write one in anyway, cron tries to execute the user name as a command and you find this in the mail or in the log:

/bin/sh: 1: root: not found

/etc/cron.d: six fields

Files in /etc/cron.d/ are system crontabs and carry a user field between the time fields and the command. This is the right place for jobs that belong to an application or to configuration management, because every file can be replaced on its own.

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""

0 3 * * * root /usr/local/bin/backup.sh >> /var/log/kh-backup.log 2>&1

If you forget the user field, cron reads the first word of the command as a user name and the line fails silently, because that user does not exist.

Three rules for /etc/cron.d/ are broken regularly:

  • The file name must not contain a dot. Only letters, digits, underscores and hyphens are allowed. backup.cron or kh-backup.sh are ignored without comment. The reason is package management: this way, leftovers such as .dpkg-dist or .dpkg-old are never executed. Just call the file kh-backup.
  • Permissions and ownership have to be right. root:root and mode 0644 are expected. Otherwise the log fills up with messages such as (*system*) WRONG FILE OWNER, (*system*) BAD FILE MODE or a warning about an insecure mode that is writable for group or others. The job then does not run.
  • The file has to end with a newline. cron requires every entry to be terminated by a newline. If the last line ends without one it is skipped, and skipped without comment: in a control test with an otherwise identical file that had no trailing newline, the job did not run a single time, with no error message and no log line. If you generate the file with echo -n, with a printf that has no trailing \n, or from a template without an empty line at the end, you lose precisely that last job.
chown root:root /etc/cron.d/kh-backup
chmod 0644 /etc/cron.d/kh-backup

/etc/crontab and the cron.* directories

/etc/crontab also has six fields and belongs to the distribution. Only change it if you know why. Through run-parts it calls the directories /etc/cron.hourly, cron.daily, cron.weekly and cron.monthly. Scripts placed there need the execute bit and must not carry a dot in their name either. A backup.sh in /etc/cron.daily/ is never executed, a backup is. You can test that without running anything:

run-parts --test /etc/cron.daily

Only the scripts that run-parts would really start are listed. If yours is missing from the list, the cause is the name or the execute bit.

Why PATH is different inside cron

This is by far the most common cause of "runs in the shell, but not in cron". cron does not start a login shell. Neither ~/.bashrc nor ~/.profile nor /etc/profile is read. For user crontabs, the Debian cron sets a minimal search path:

PATH=/usr/bin:/bin

That leaves out /usr/local/bin and /usr/sbin. On Debian 12, Debian 13, Ubuntu 22.04 and Ubuntu 24.04, /usr/bin and /usr/sbin are still separate directories, since the usr merge only covers /bin and /sbin. Everything you put into /usr/local/bin yourself, everything from pip install, everything from a Node install managed by nvm, and system tools such as ufw or iptables simply do not exist as far as cron is concerned. The error message then reads:

/bin/sh: 1: backup.sh: not found

The second half of the trap: cron sets SHELL=/bin/sh. On Debian and Ubuntu, /bin/sh points to dash, not to bash. Every bash idiom in the script header or directly in the crontab line fails:

/bin/sh: 1: [[: not found
/bin/sh: 1: source: not found
/bin/sh: 1: Syntax error: "(" unexpected

Three countermeasures, in this order:

  1. Use absolute paths. /usr/local/bin/backup.sh instead of backup.sh, /usr/bin/php instead of php. This is the most robust variant, because it works independently of any environment variable. Read the path off the system instead of typing it from memory: command -v date returns /usr/bin/date on Debian 13, Debian 12, Ubuntu 24.04 and Ubuntu 22.04, but /bin/date on older systems such as Debian 11. crontab accepts a line with a wrong path without comment and without warning, and it only fails at runtime, silently. To verify, run crontab -l once and env -i /bin/sh -c "/usr/bin/date" once, which reports a wrong path straight away with not found.
  2. Set PATH and SHELL at the top of the crontab. The assignments apply to all following lines. Important: cron does not expand variables here, so PATH=$PATH:/opt/bin does not work. Write the path out in full.
  3. Use a wrapper script. The crontab line only calls the script, and the script sets up its own environment.
#!/bin/bash
set -euo pipefail
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
cd /srv/app
exec ./do-the-work.sh

If you want to know what cron really hands to your job, have it show you. Add this for one minute:

* * * * * /usr/bin/env > /tmp/cron-env.txt 2>&1

Only this route shows the environment cron actually sets. A shell rebuilt with env -i merely comes close, because it brings along its own default path. Then compare /tmp/cron-env.txt with your interactive environment. What stands out is usually not just PATH and SHELL, but the missing locale variables as well. Without LANG everything runs in the C locale, which changes sort order, date formats and the output of accented characters. Scripts that rely on LANG=de_DE.UTF-8 behave differently inside cron. The ssh-agent is missing too, which is why jobs with SSH access need a key without a passphrase and an explicit -i.

The last small nasty surprise in this category: the percent sign. In a crontab line an unescaped % is turned into a newline, and everything after it goes to the command as standard input. Date formats therefore have to be escaped.

0 2 * * * /usr/bin/tar -czf /backup/web-$(date +\%F).tar.gz /var/www

Redirecting output, mail and "No MTA installed"

By default, cron mails everything a job writes to stdout or stderr to the owner of the crontab. On a server without a mail system this ends up in the log:

(CRON) info (No MTA installed, discarding output)

This is not a failure of the job. It only means that output was produced and nobody could accept it. A silent job does not generate this line. That even makes it a useful signal: if it suddenly shows up, your job has started producing output, usually an error message.

There are three sensible patterns for the redirection:

# everything into a log file, errors included
0 3 * * * /usr/local/bin/backup.sh >> /var/log/kh-backup.log 2>&1

# drop normal output, still send errors by mail
0 3 * * * /usr/local/bin/backup.sh > /dev/null

# everything into the journal, clearly tagged
0 3 * * * /usr/local/bin/backup.sh 2>&1 | /usr/bin/logger -t kh-backup

The variant > /dev/null 2>&1 is popular and dangerous: it throws away every error message as well. A job that has been failing for four months then looks exactly like one that works. If you want to do without mail, set MAILTO="" at the top of the crontab instead and write the output to a file. If you want mail sent to a specific address, set MAILTO=alerts@example.org, but that needs an installed mail system.

Your own log files in /var/log/ grow without limit. Add a small rule for them in /etc/logrotate.d/, otherwise your most successful cron job will fill the disk sooner or later.

How to tell that it really ran

This is where the quick guide parts ways with a check you can rely on. cron logs the start of a job. It logs neither the end nor the exit code. A CMD line in the log therefore only proves that cron started the shell, not that your script succeeded.

How you read the logs depends on the system:

journalctl -u cron --since "30 min ago"
journalctl -t CRON --since today

On Debian 12 and Debian 13 this is the only way, because rsyslog has not been installed by default since bookworm. A file /var/log/syslog usually no longer exists there. If you look for it and do not find it, you may wrongly conclude that the job never started.

On Ubuntu 22.04 and 24.04 rsyslog is usually present, so this works there as well:

grep CRON /var/log/syslog

None of the four versions ships a dedicated /var/log/cron.log. The corresponding rule in /etc/rsyslog.d/50-default.conf is commented out. Do not go looking for this file, it only exists if somebody enabled it.

Solid proof therefore has to come from the job itself. Have the script write a timestamp and the exit code at the end:

#!/bin/bash
set -euo pipefail
trap 'echo "$(date -Is) kh-backup finished, exit $?" >> /var/log/kh-backup.log' EXIT
# the actual work

That gives you three pieces of evidence instead of one: the CRON line in the journal (cron started it), the closing line in your log file (the script reached the end) and the exit code (it finished cleanly). Only when all three line up does the job really work.

If a job can run longer than its own interval, protect it against overlapping runs as well. Otherwise ten instances will start in parallel at some point and drag the server down:

*/5 * * * * /usr/bin/flock -n /var/lock/kh-sync.lock /usr/local/bin/sync.sh

flock -n exits immediately if an instance is already running. The tool sits in util-linux and is present on all four systems.

@reboot and why systemd is usually the better option

@reboot lets you run a command at startup. There are also @daily, @hourly, @weekly, @monthly and @yearly, each of which replaces the five time fields.

@reboot /usr/local/bin/start-app.sh

That looks convenient and has three serious weaknesses:

  • The trigger is not "after boot", it is "when cron starts". Whether the network, the database or a mount is ready at that moment is pure luck. The usual stopgap is a sleep 30 in front of it, which only postpones the problem.
  • Restarting the cron service triggers @reboot again. A systemctl restart cron, after a package update for instance, starts your application a second time even though the first one is still running.
  • There is no supervision. No exit code, no restart after a crash, no status.

Anything meant to run permanently belongs in a systemd service, not in a cron job. For recurring tasks, a timer is the better choice. Two files are enough:

[Unit]
Description=KernelHost Backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
[Unit]
Description=KernelHost Backup daily

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=900

[Install]
WantedBy=timers.target

You enable the timer, not the service:

systemctl daemon-reload
systemctl enable --now kh-backup.timer
systemctl list-timers kh-backup.timer

The three decisive advantages over cron: Persistent=true catches up a missed run after the next boot, while cron skips it without a word. The exit code shows up in systemctl status kh-backup.service, so you can see whether it worked without any logging of your own. And the complete output is available through journalctl -u kh-backup.service, with no redirection and no mail. The search path in systemd is more generous than cron's, by the way, and includes /usr/local/bin by default, though it still stays fairly short if you add nothing yourself.

For startup tasks, replace @reboot with a service that has clear dependencies:

[Unit]
After=network-online.target
Wants=network-online.target

That guarantees your job only runs once the network is really up. You will find more about units and how they are built in creating a systemd service.

Time zone, UTC and daylight saving time

cron always calculates in the system time zone taken from /etc/localtime. On many servers and in almost all cloud images that is UTC. A job set to 0 3 * * * then runs at 05:00 Central European Summer Time, not at 03:00. Check first what you are dealing with:

readlink -f /etc/localtime
timedatectl show -p Timezone --value
date
date -u

readlink -f /etc/localtime names the path inside /usr/share/zoneinfo and with it the zone that is actually in effect, for example /usr/share/zoneinfo/Etc/UTC or /usr/share/zoneinfo/Europe/Vienna. It works on all four versions and also where systemd is not running. timedatectl show -p Timezone --value returns the same information in a short, machine-readable form, but it requires systemd.

One habit worth dropping is the look into /etc/timezone. Debian 13 no longer ships this file, a cat /etc/timezone ends there with cat: /etc/timezone: No such file or directory, and installing tzdata does not bring it back either. On Debian 12, Ubuntu 24.04 and Ubuntu 22.04 it still exists, so the answer differs from version to version. The only authoritative source in every case is the symlink /etc/localtime: a value written into /etc/timezone by hand changes nothing about the system time zone, and therefore nothing about when your jobs fire.

You can switch the system time zone at any time, and afterwards you should restart cron so that the service reliably picks up the change:

timedatectl set-timezone Europe/Vienna
systemctl restart cron

On systems without a running systemd, set the symlink directly instead. The result is the same and you can verify it straight away with readlink -f /etc/localtime:

ln -sf /usr/share/zoneinfo/Europe/Vienna /etc/localtime

Now the point that many guides get wrong: the cron on Debian and Ubuntu does not know CRON_TZ. That variable comes from cronie, the cron of Red Hat, Fedora and AlmaLinux. On Debian 13, Debian 12, Ubuntu 24.04 and Ubuntu 22.04 there is no time zone per user or per crontab. If you put TZ=Europe/Vienna into the crontab there, it affects only the environment of the executed commands, so a date inside the script shows Vienna time. The trigger time itself remains completely untouched and still follows the system time zone. Overlook that and you have a job that looks correctly configured and still runs two hours off.

That leaves three clean options: set the system time zone appropriately, convert the times to UTC yourself, or switch to a systemd timer, which accepts a time zone directly in the schedule. You can check it without any risk:

systemd-analyze calendar "Mon..Fri 03:00 Europe/Vienna"

The output names the next trigger as a concrete date. That is the most reliable check you can run before you enable anything.

One practical piece of advice on daylight saving time: do not schedule jobs between 02:00 and 03:00. In March that hour does not exist, in October it exists twice. Depending on the job this means either a missed run or a duplicate one, both once a year and both hard to reproduce. 01:30 or 03:30 are inconspicuous alternatives. With systemd timers, Persistent=true additionally helps to catch up a missed run.

Fast troubleshooting in seven steps

If a cron job does not run, work through this list in order. It covers practically every case.

  1. Is the service running? systemctl status cron. No service, no job.
  2. Did the entry arrive? crontab -l for user jobs, otherwise look at the file in /etc/cron.d/. Check for a file name without a dot, mode 0644, owner root and the trailing newline.
  3. Did cron start it at all? journalctl -t CRON --since today. If the CMD line is missing, the schedule or the file is the problem, not the script.
  4. Is the field count right? Five fields in the user crontab, six in /etc/cron.d and /etc/crontab.
  5. Absolute paths? Enter every command and every script with its full path, and determine that path with command -v beforehand instead of typing it.
  6. Environment checked? Add * * * * * /usr/bin/env > /tmp/cron-env.txt 2>&1 once, wait a minute, compare.
  7. Test in the cron context. Do not run the script in your own shell, run it with an empty environment: env -i PATH=/usr/bin:/bin /bin/sh -c '/usr/local/bin/backup.sh'. You pass the search path deliberately. env -i /bin/sh on its own does clear the environment, but dash then sets its own default path /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin, and that one is more generous than cron's. Exactly the errors this article is about would stay undetected that way. If the call fails, you have found the cause, without waiting for the next trigger time.

The last point is the most valuable one. Almost every cron job that "inexplicably" does not work fails reproducibly as soon as you start it with an empty environment. That turns a mystery which only appears every 24 hours into a bug you can fix in ten seconds.

If you run backups by cron job on a regular basis, you should also harden the target side. Suitable pointers are in our article on securing a Linux server.

Frequently asked questions

Why does my script run in the shell but not as a cron job?
In the vast majority of cases it comes down to the environment. cron does not start a login shell, so it reads neither ~/.bashrc nor /etc/profile, and for user crontabs it sets only PATH=/usr/bin:/bin. That leaves out /usr/local/bin and /usr/sbin. On top of that, SHELL is set to /bin/sh, which on Debian and Ubuntu is dash and does not understand bash syntax. Test the job in exactly that environment: env -i PATH=/usr/bin:/bin /bin/sh -c, followed by the script path in single quotes ('/path/to/script.sh'), and it fails immediately and reproducibly. Pass the search path explicitly, because a bare env -i /bin/sh sets the more generous default path of dash and hides this very error.
What does the message No MTA installed, discarding output mean?
Your job wrote something to stdout or stderr, cron wanted to deliver it by mail, but no mail system is installed. The job itself is not affected and may well have succeeded. Either redirect the output into a log file, or set MAILTO="" at the top of the crontab. Note that the message often shows up only once a previously silent job starts emitting errors.
Do Debian or Ubuntu support the CRON_TZ variable?
No. CRON_TZ comes from cronie, the cron of Red Hat and Fedora. Debian 13, Debian 12, Ubuntu 24.04 and Ubuntu 22.04 use the Debian variant of Vixie Cron and have no time zone per crontab. A TZ= in the crontab affects only the environment of the commands, not the trigger time. Either set the system time zone with timedatectl, convert the times to UTC, or use a systemd timer, which accepts a time zone in the OnCalendar expression.
Why is my file in /etc/cron.d ignored?
Almost always because of the file name. Only letters, digits, underscores and hyphens are allowed, and a dot makes the file invisible to cron. So backup.cron is ignored, backup is not. Other reasons: wrong permissions (root:root and 0644 are required), a missing user field between the time fields and the command, or a last line without a trailing newline.
Where do I find the cron logs on Debian 12 and 13?
Through journalctl, because rsyslog has not been installed by default since Debian 12 and a file /var/log/syslog usually does not exist there. Use journalctl -u cron or journalctl -t CRON. On Ubuntu 22.04 and 24.04, grep CRON /var/log/syslog works as well. None of the four systems ships a dedicated /var/log/cron.log.
Does a CMD line in the log prove that the job succeeded?
No. cron logs only the start of a job, neither the end nor the exit code. A script can crash one second later and the log line looks identical. For real proof, have the script itself write a timestamp and the exit code into a log file, for example through a trap on EXIT, or switch to a systemd timer, where systemctl status shows the exit code.
Should I use @reboot or a systemd service?
In almost every case a systemd service. @reboot does not run after the boot, it runs when the cron service starts, with no guarantee that the network or the database is ready. It is also triggered again by every systemctl restart cron, after a package update for instance. A service with After=network-online.target and Wants=network-online.target solves both problems and adds a status display and an exit code.

Cron Crontab systemd Linux Administration Debian Ubuntu Automation Server Management