Cron job not running? How to find out why

Cron can skip your job and never say a word about it.

Name a file /etc/cron.d/backup.sh on Debian or Ubuntu and cron ignores it. No error. No log line. The dot in the name is enough.

That is the worst case, and most of the others are kinder. This guide splits the problem in two, because the fixes do not overlap. Either cron never started the job, or it started the job and the job failed. One log line tells you which.

Everything below was checked against the source of Debian’s cron (3.0pl1, used by Debian 12, Debian 13 and Ubuntu 24.04) and cronie (used by RHEL, Rocky, Alma and Fedora).

Step 1: Did cron start it?

Cron writes one line when it starts a job. Look for it:

# Debian, Ubuntu
journalctl -t CRON --since today | grep "CMD ("

# RHEL, Rocky, Alma, Fedora
journalctl -t CROND --since today | grep "CMD ("

Run these as root, or as a user in the adm or systemd-journal group. Anyone else sees only their own entries, which for cron is nothing.

The tags are uppercase on purpose. Cron’s job lines carry CRON or CROND. The daemon’s own complaints carry lowercase cron or crond. You need both in a minute.

A match looks like this:

Oct 11 03:00:01 web1 CRON[2453]: (root) CMD (/usr/local/bin/backup.sh)

There is a CMD line for your job. Cron did its part. The job ran and failed. Skip to Step 3.

There is no CMD line. Cron never started it. Read on.

No CMD lines at all, for any job? Then cron’s logging is off or the log is somewhere else. How to check cron logs on Linux covers the five reasons the log comes back empty.

Step 2: Cron never started it

Ask the daemon what it rejected. These are the lowercase tags:

# Debian, Ubuntu
journalctl -t cron --since "2 days ago"

# RHEL family
journalctl -t crond --since "2 days ago"

Then work down this list. It runs from silent to loud.

The file name has a dot in it (Debian and Ubuntu)

Debian’s cron loads a file from /etc/cron.d only if its name is letters, digits, underscores and hyphens. Anything else is skipped. Nothing is logged.

So backup.sh, backup.conf and backup.bak are all ignored. backup and backup-nightly load.

List the files cron is skipping:

ls /etc/cron.d | grep '[^A-Za-z0-9_-]'

Rename them. Cron notices the change within a minute. No restart needed.

Cronie is more relaxed. It skips names that start with . or #, end in ~, or end in .rpmsave, .rpmorig or .rpmnew. Dots are otherwise fine there. So a file that works on a Rocky box can vanish when you copy it to Ubuntu.

The last line has no newline

Debian’s cron wants every line to end in a newline, including the last one. If the last one does not, cron ignores the whole file:

cron[812]: (*system*backup) ERROR (Missing newline before EOF, this crontab file will be ignored)

Cronie is gentler. It drops only the last line, and logs missing newline before EOF.

Editors that write files without a final newline cause this. So does printf in a provisioning script. Check:

tail -c 1 /etc/cron.d/backup | od -c

You want \n. Anything else, add one:

echo >> /etc/cron.d/backup

crontab -e and crontab file refuse to install a file like this, so user crontabs are safe. Files dropped straight into /etc/cron.d are not.

The permissions are wrong

Both crons refuse files that someone else could edit. The rules differ, and one of them catches people who mean well.

Debian and Ubuntu, /etc/cron.d: owned by root, a regular file, not writable by group or others. The execute bit does not matter.

Cronie, /etc/cron.d: owned by root, and the mode must pass a stricter mask. 0644 and 0600 work. 0755 is rejected, because the owner execute bit is forbidden too. 0664 is rejected as well.

That means chmod +x on a cron file breaks it on RHEL. It looks like the helpful thing to do.

The log says so, under the lowercase tag:

cron[812]: (*system*backup) INSECURE MODE (group/other writable) (/etc/cron.d/backup)
crond[901]: (root) BAD FILE MODE (/etc/cron.d/backup)

Fix both families at once:

chown root:root /etc/cron.d/backup
chmod 644 /etc/cron.d/backup

WRONG FILE OWNER means the same thing for the owner.

The user field is missing

User crontabs have five time fields and then a command. /etc/crontab and every file in /etc/cron.d have a sixth field: the user to run as.

# crontab -e: no user field
0 3 * * * /usr/local/bin/backup.sh

# /etc/cron.d/backup: user field required
0 3 * * * root /usr/local/bin/backup.sh

Copy a line from one into the other and it breaks. Without the field, cron takes the first word of your command as a user name.

Debian rejects the user and then drops the entire file:

cron[812]: Error: bad username; while reading /etc/cron.d/backup
cron[812]: (*system*backup) ERROR (Syntax error, this crontab file will be ignored)

That second line applies to any syntax error in a Debian system crontab. One bad line takes every job in the file with it.

Cronie skips only the bad line. It logs the problem when the job comes due:

crond[901]: (/usr/local/bin/backup.sh) ERROR (getpwnam() failed - user unknown)

You edited the crontab file directly

User crontabs live in /var/spool/cron/crontabs/ on Debian and /var/spool/cron/ on RHEL. Debian’s cron rereads them only when that directory’s timestamp changes. crontab -e makes sure it does. Editing the file in place does not.

So an edit made with vim /var/spool/cron/crontabs/alice can sit unread. Use crontab -e, or crontab -u alice -e as root.

Debian also wants that file at mode 0600 exactly, owned by the user. Anything else logs INSECURE MODE (mode 0600 expected).

Cronie watches its directories with inotify, so direct edits are picked up. Edits to /etc/crontab and /etc/cron.d are picked up by both.

The schedule does not mean what you think

Cron did not skip the job. It just was not due.

Day of month and day of week combine with OR. This line runs on the 1st, the 15th, and every Friday:

30 4 1,15 * 5 /usr/local/bin/report.sh

Most people read it as “the 1st and 15th, if they are Fridays”. Cron does not. When both fields are restricted, either one matching is enough.

Unless one of them starts with *. A field that begins with an asterisk counts as unrestricted, even */2. Then the two fields combine with AND. So this runs only on Sundays that fall on an odd date:

0 0 */2 * sun /usr/local/bin/job.sh

The time is the server’s time. Debian’s cron schedules in the system time zone. Setting TZ= in the crontab changes the job’s environment, not when it runs. Cronie accepts CRON_TZ=Europe/Berlin per crontab. Check what the server thinks:

timedatectl

A cloud server on UTC runs your “3 a.m.” job at 3 a.m. UTC.

@reboot means when cron starts. Debian records a boot in /run/crond.reboot. Restart the cron service and @reboot jobs do not run again. The log says Skipping @reboot jobs -- not system startup.

The account has expired

Cron runs every job through PAM. If the user’s password has expired, or the account has, the job does not start. There is no CMD line.

Debian logs only the PAM message, with no user name:

CRON[3301]: Authentication token is no longer valid; new one required

Cronie names the user: (alice) PAM ERROR (...). Check the account:

chage -l alice

On Debian this applies to root’s jobs too. Cronie skips PAM for root jobs in /etc/crontab and /etc/cron.d.

One thing that is not the cause: cron.allow and cron.deny. Only the crontab command reads them. Adding a user to cron.deny stops them editing their crontab. Their existing jobs keep running.

Step 3: It ran and failed

Cron logged CMD, so it did start the job. The job then died in cron’s environment, which is much smaller than your shell’s.

The environment is nearly empty

Cron does not read .bashrc, .profile or /etc/profile. A job gets this:

Debian 12 and 13 RHEL 9 (cronie 1.5.7)
SHELL /bin/sh /bin/sh
PATH /usr/bin:/bin /usr/bin:/bin
HOME from /etc/passwd from /etc/passwd
LOGNAME the user the user
USER not set the user
working directory $HOME $HOME

That table explains most failures:

  • command not found. Anything in /usr/local/bin, /usr/sbin or /snap/bin is off the PATH. Use full paths, or set PATH= at the top of the crontab.
  • Bash syntax under /bin/sh. On Debian and Ubuntu, /bin/sh is dash. [[ ]], source and arrays fail there. Put SHELL=/bin/bash in the crontab, or call a script with a #!/bin/bash line.
  • Relative paths. They resolve from $HOME, not from wherever the script lives.
  • Missing variables. Credentials exported in .bashrc do not exist under cron.

Debian’s /etc/crontab sets a longer PATH. That PATH does not carry over to files in /etc/cron.d. Each file starts from the defaults.

Ubuntu 24.04 patched this. Its cron no longer sets a PATH itself. Jobs take it from PAM, which reads /etc/environment. Set PATH= in the crontab and the question goes away on every release.

To reproduce the failure, run the command in a cron-shaped environment:

env -i HOME="$HOME" LOGNAME="$USER" SHELL=/bin/sh PATH=/usr/bin:/bin \
  /bin/sh -c '/usr/local/bin/backup.sh'

If it fails there, you have found the problem. If it works there and fails under cron, look at the next section.

A % in the command

Cron treats % as a newline. Everything after the first one is fed to the command as standard input. So this line hands the shell tar czf /backup/site-$(date + and stops there:

0 3 * * * tar czf /backup/site-$(date +%F).tar.gz /var/www

The shell fails on the unclosed $(, and no archive is written. Escape it as \%, or move the command into a script. This applies to every crontab, including /etc/cron.d.

Where the error message went

The job printed an error. Cron collected it. What happened next depends on the distribution and on whether the server can send mail.

Debian and Ubuntu with no mail server throw the output away and say so:

CRON[2460]: (CRON) info (No MTA installed, discarding output)

That line is proof the job printed something. The text is gone.

RHEL with no mail server does better. At startup cronie notices there is no sendmail and logs Syslog will be used instead of sendmail. From then on, each line of output goes to the journal as a CMDOUT line:

journalctl -t CROND --since today | grep CMDOUT

MAILTO="" in the crontab discards output on purpose. Debian logs nothing. Cronie still writes CMDOUT lines when it is in syslog mode.

To keep the output yourself, redirect it in the crontab line. Where cron job output goes covers doing that without losing stderr or splitting one run into a hundred rows.

Getting the exit status logged

By default neither cron logs whether a job succeeded.

Debian’s cron can. Its -L option takes a bitmask: 1 logs job starts (the default), 2 logs job ends, 4 logs jobs that exit non-zero. On Debian 12 and 13, set it in /etc/default/cron:

EXTRA_OPTS="-L 5"

Then restart cron:

sudo systemctl restart cron

A failing job now leaves a second line:

CRON[2453]: (CRON) error (grandchild #2454 failed with exit status 1)

On Ubuntu 24.04, /etc/default/cron is only a comment. Set the variable in a drop-in instead. Run sudo systemctl edit cron.service, add these two lines, save, and restart cron:

[Service]
Environment="EXTRA_OPTS=-L 5"

Cronie never logs exit status. It logs CMD and CMDEND, and nothing in between says how it went.

Step 4: Hear about it next time

Every check above is something you run after you notice. The trouble is noticing. A job that never starts produces no error to alert on.

The fix is to have the job report in. Central Logging’s cron monitoring waits for a ping at the end of each run:

0 3 * * * root /usr/local/bin/backup.sh && curl -fsS "https://logs.example.com/p/YOUR-API-KEY/nightly-backup?state=complete"

If the ping stops arriving, you get an alert. It does not matter which cause from Step 2 or Step 3 stopped it. A dot in a file name and a full disk look the same from outside, and both get caught.

There is no schedule to type in. Central Logging learns the interval from the gap between recent completed runs, and flags a job once it is 20% overdue. It needs two completed runs before it can call one late.

To hear about a failure straight away rather than when the ping is overdue, send state=fail from the script. The wrapper is in monitoring cron jobs.

The daemon’s own rejections are worth an alert too. Once the CL Agent ships the journal, this alert rule fires when cron refuses a file on any host. Set it to Alert on results:

SELECT 1 FROM logs
WHERE json_valid(msg)
  AND json_extract(msg,'$.SYSLOG_IDENTIFIER') IN ('cron','crond')
  AND (json_extract(msg,'$.MESSAGE') LIKE '%will be ignored%'
       OR json_extract(msg,'$.MESSAGE') LIKE '%INSECURE MODE%'
       OR json_extract(msg,'$.MESSAGE') LIKE '%BAD FILE MODE%'
       OR json_extract(msg,'$.MESSAGE') LIKE '%WRONG FILE OWNER%'
       OR json_extract(msg,'$.MESSAGE') LIKE '%bad username%'
       OR json_extract(msg,'$.MESSAGE') LIKE '%getpwnam() failed%')
  AND "timestamp" > CAST(strftime('%s','now','-10 minutes') AS INTEGER)
LIMIT 1;

It cannot catch the dot in a file name. Nothing logs that. The ping can.

Where to go next

💌 Get notified on new features and updates

Only sent when a new version is released. Nothing else.