Why Your Cron Job Isn't Running

A systematic checklist for jobs that never fire, fire twice, or fail silently

A cron job that does not run gives you very little to work with. There is no error message, no output and no stack trace. The failure is usually in one of the nine places below. Check them in a fixed order, because each check rules out a whole class of causes. This guide follows that order, starting with "is the daemon even running" and ending with "the script works but writes to a directory it cannot see".

1. Is cron running, and did it read your file?

Start here, because every later step assumes the daemon is running. Minimal container images often ship without a cron daemon, and a stopped daemon looks the same as a broken schedule.

systemctl status cron      # Debian/Ubuntu
systemctl status crond     # RHEL/Fedora/Alma
ps aux | grep -w '[c]ron'  # anywhere, including containers

Then confirm that cron accepted your crontab. crontab -l prints what is installed, which is not always what you think you saved. If the editor exited non-zero or the file failed validation, cron keeps the previous version:

crontab -l                 # your user crontab
sudo crontab -l -u deploy  # someone else's
ls -la /etc/cron.d/        # drop-in files
Three ways a crontab is ignored entirely

Under run-parts semantics, a file in /etc/cron.d/ whose name contains a dot is skipped, so backup.cron never runs while backup does. A crontab file with no trailing newline can lose its last line. A line in /etc/cron.d/ or /etc/crontab that omits the username field is malformed, because those files take six fields before the command instead of five.

2. Read the cron log

Cron logs every job it starts. If there is no log line, cron never tried to run the job, and you should go back to the schedule or the crontab. If there is a log line, cron started the job and the problem is in the command.

journalctl -u cron --since "2 hours ago"      # Debian/Ubuntu
journalctl -u crond --since "2 hours ago"     # RHEL family
grep CRON /var/log/syslog                     # older Debian
tail -f /var/log/cron                          # older RHEL

A successful start looks like CRON[12345]: (deploy) CMD (/usr/local/bin/nightly.sh). Two other lines are worth recognising:

3. Is the schedule what you think it is?

A syntactically valid expression can describe a different schedule from the one you intended, and cron will not warn you. Paste the line into the Explainer and read the next five run times. Most mistakes of this kind come from three patterns:

Also check the time zone. If the job runs an hour or several hours off, it is almost certainly firing correctly in the server's zone rather than yours (see the time zones guide). Confirm the zone by running date on the server instead of on your laptop.

4. The environment: PATH, shell and variables

For jobs that cron does start, the environment is by far the most common cause of failure. Cron does not source .bashrc, .bash_profile, .profile or any other shell init file. A cron job gets a near-empty environment, typically PATH=/usr/bin:/bin, SHELL=/bin/sh and HOME, with none of the variables you added yourself.

In practice, this breaks the following:

The fix is to make the script self-sufficient rather than to pile assignments into the crontab:

#!/usr/bin/env bash
set -euo pipefail

export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
[ -f /etc/myapp/env ] && . /etc/myapp/env

cd /srv/myapp
exec /usr/local/bin/node ./tasks/nightly.js

Variable assignments in a crontab are literal strings, and no shell evaluates them. PATH=$PATH:/opt/bin stores the characters $PATH:/opt/bin without expanding them.

5. Absolute paths and the working directory

Cron starts the job in the user's home directory, not in the directory that contains the script, so every relative path in the job is resolved against $HOME. A script that reads ./config.yml and works when you run it from the project directory will fail under cron. Worse, it may silently read a different file.

Use absolute paths for the interpreter, the script, the data and the log:

# Fragile
0 3 * * * cd myapp && ./run.sh

# Robust
0 3 * * * /usr/local/bin/run-myapp.sh >> /var/log/myapp.log 2>&1

Inside a script, a reliable approach is to change to the script's own directory first:

cd "$(dirname "$(readlink -f "$0")")"

6. Permissions and ownership

Check each of these against the user the job runs as. In a system crontab, that is the user named in the sixth field, not the owner of the file:

7. It runs, but it fails silently

If the log shows the job starting and nothing happens afterwards, the job is running and failing. The output that would tell you why is sent to a mail system that does not exist and then discarded.

Capture that output in a log file:

0 3 * * * /usr/local/bin/job.sh >> /var/log/job.log 2>&1

The 2>&1 must come after the file redirect. If you reverse them, stderr still goes to the old destination. Next, record the exit code, because a script without set -e continues past a failed command, and its exit code only reflects the last command it ran:

0 3 * * * /usr/local/bin/job.sh >> /var/log/job.log 2>&1; echo "exit=$? at $(date -Is)" >> /var/log/job.log
The anti-pattern to remove first

Adding > /dev/null 2>&1 to a job you are debugging discards the only evidence you have. It can be a reasonable production setting once the job is known to work and is monitored another way, but it is never a reasonable setting while you are debugging.

The job may also be dying rather than failing. A long-running job killed by the OOM killer leaves nothing in the job's own log. Check dmesg -T | grep -i oom and the system journal around the run time.

8. It runs twice, or overlaps itself

Duplicate runs usually have one of four causes:

For the last case, the standard fix is flock, which takes one line:

0 * * * * /usr/bin/flock -n /tmp/myjob.lock /usr/local/bin/myjob.sh

-n makes a second instance exit immediately rather than queue up. The best practices guide covers locking, idempotency and monitoring in more depth.

9. Reproducing cron's environment exactly

To settle quickly whether the environment is the problem, run the job the way cron would, with the environment stripped entirely:

env -i /bin/sh -c '/usr/local/bin/job.sh'

If that fails and your interactive run succeeds, the cause is in the environment: the path, the shell or a missing variable.

To see precisely what cron gives your jobs on this machine, add a temporary every-minute job that dumps the environment, then read the result:

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

Let it fire once, remove the line, then cat /tmp/cron-env.txt. Diff that against the env output from your interactive shell to see which variables are missing.

A two-minute reproduction loop

Waiting until 03:00 to test a nightly job makes every attempt take a day. Instead, schedule it temporarily for two minutes from now with a wildcard-free expression, watch the log, and then restore the real schedule. Keep the real line in the file as a comment so you cannot forget to put it back.

Still stuck?

Verify the schedule one more time against the computed run times in the Explainer. After several hours of debugging a command, it is easy to miss that the job is not due when you think it is. The FAQ covers narrower questions, and the crontab guide covers the file syntax itself, including the percent-sign escaping rule that breaks any command containing date +%Y.