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 almost nothing to work with: no error, no output, no stack trace. The failure is usually in one of six places, and they are worth checking in a fixed order, because each one rules out a whole class of causes. This guide is that order — from "is the daemon even running" down to "the script works but writes to a directory it cannot see".

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

Start here, because everything downstream assumes it. Minimal container images frequently ship without a cron daemon at all, and a stopped daemon looks exactly like 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 cron actually 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

A file in /etc/cron.d/ whose name contains a dot is skipped by run-parts semantics — backup.cron never runs, backup does. A crontab file with no trailing newline can lose its last line. And 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, not five.

2. Read the cron log

Cron logs every job it starts. If there is no log line, cron never tried — which sends you 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 encode a completely 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. Three patterns account for most of these:

Also check the time zone. If the job runs but 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 with date on the server, not on your laptop.

4. The environment: PATH, shell and variables

This is the most common cause by a wide margin for jobs that cron does start. Cron does not source .bashrc, .bash_profile, .profile or anything else. A cron job gets a near-empty environment: typically PATH=/usr/bin:/bin, SHELL=/bin/sh, HOME, and nothing you added yourself.

What that breaks, in practice:

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

Note that variable assignments in a crontab are literal, not shell expressions. PATH=$PATH:/opt/bin stores the characters $PATH:/opt/bin — it does not expand.

5. Absolute paths and the working directory

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

Make the interpreter, the script, the data and the log absolute:

# 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, anchoring to its own location is a reliable pattern:

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

6. Permissions and ownership

Check each of these against the user the job actually runs as — which in a system crontab is the sixth field, not the file's owner:

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 being mailed to a mail system that does not exist, and discarded.

Capture it:

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

The 2>&1 must come after the redirect; reversed, stderr still goes to the old destination. Then check the exit code, because a script without set -e happily continues past a failed command and exits 0:

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

> /dev/null 2>&1 on a job you are debugging discards the only evidence you have. It is a reasonable production setting once the job is known good and monitored another way — it is never a reasonable debugging setting.

Also consider that the job may 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, flock is the standard fix and costs 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

The fastest way to end an argument about whether the environment is the problem is to run the job the way cron would. Strip the environment entirely:

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

If that fails and your interactive run succeeds, the cause is environmental — path, shell or a missing variable.

To see precisely what cron gives your jobs on this machine, schedule a one-off dump a couple of minutes ahead and 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 your interactive env output and the missing pieces are immediately obvious.

A two-minute reproduction loop

Waiting for 03:00 to test a nightly job is a poor feedback loop. Temporarily schedule it for two minutes from now with a wildcard-free expression, watch the log, then restore the real schedule. Keep the real line in the file, commented out, 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 worth ruling out that the job simply 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.