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".
- Is cron running, and did it read your file?
- Read the cron log
- Is the schedule what you think it is?
- The environment: PATH, shell and variables
- Absolute paths and the working directory
- Permissions and ownership
- It runs, but it fails silently
- It runs twice, or overlaps itself
- Reproducing cron's environment exactly
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
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:
(CRON) info (No MTA installed, discarding output): your job produced output and nobody received it. That output often contains the error you are looking for, so redirect it to a file and run the job again.(deploy) BAD FILE MODEorWRONG FILE OWNER: cron refuses crontab files with unexpected permissions or ownership, most often after a file was copied into/etc/cron.d/by a deploy script.
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:
- Both day-of-month and day-of-week restricted. Standard cron uses OR logic, so
0 9 1-7 * 1runs on days 1-7 and also on every Monday, rather than only on the first Monday. - A wildcard left in the minute field.
* 3 * * *runs every minute of the 03:00 hour, sixty times in all, instead of once. - A day that does not exist.
0 0 31 * *never fires in the five months that have fewer than 31 days.
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:
- Anything in
/usr/local/bin. That directory is not on the default path, sodocker,aws,psqland your own scripts all fail with "command not found". - Version managers. nvm, rbenv, pyenv, asdf and similar tools work by modifying
PATHin a shell init file that cron never reads. Under cron,nodeandpythonresolve to the system version, or are not found at all. - Bashisms under
/bin/sh.[[ ]],&>, arrays,sourceand process substitution may all fail ifshis dash rather than bash. - Secrets and config. Anything exported by your login shell, such as API tokens,
DATABASE_URLorAWS_PROFILE, is absent.
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:
- Is the script executable?
chmod +x /usr/local/bin/job.sh. If it is not and the crontab invokes it directly, the job fails with a permission error that cron mails to nobody. - Does the script have a shebang? Without one,
/bin/shtries to interpret it, which quietly breaks bash- or python-specific syntax. - Can that user traverse every directory in the path? A script in a home directory set to mode 700 is unreachable by
www-data. - Can it write the log and the output directory? A job that runs as root by hand and as an unprivileged user under cron often fails on exactly one
mkdir. - Are there line-ending problems? A script edited on Windows and saved with CRLF gives
bad interpreter: /bin/bash^M. Fix withdos2unix.
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
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:
- The same job installed in two places, such as a user crontab and an
/etc/cron.d/drop-in, or a file owned by config management plus a hand-edited one. Grep for the script name across/var/spool/cron/,/etc/crontaband/etc/cron.d/. - The job scheduled on more than one host behind a load balancer, where each instance has the same crontab.
- A daylight saving fall-back, in which a wall-clock hour occurs twice. See the DST section.
- A previous run that has not finished. Cron starts a new instance on schedule even if the last one is still running, because it has no built-in mutual exclusion.
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.