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".
- 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 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
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:
(CRON) info (No MTA installed, discarding output)— your job produced output and nobody received it. That output is very often the error you are looking for. Redirect it to a file and run 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 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:
- Both day-of-month and day-of-week restricted. Standard cron uses OR logic, so
0 9 1-7 * 1is not "the first Monday" — it runs on days 1–7 and on every Monday. - A wildcard left in the minute field.
* 3 * * *runs sixty times, not once. - A day that does not exist.
0 0 31 * *skips five months a year.
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:
- Anything in
/usr/local/bin. Not on the default path.docker,aws,psql, your own scripts — all "command not found". - Version managers. nvm, rbenv, pyenv, asdf and friends work by modifying
PATHin a shell init file cron never reads.nodeandpythonresolve to the system version, or not 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 — API tokens,
DATABASE_URL,AWS_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
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:
- Is the script executable?
chmod +x /usr/local/bin/job.sh. If it is not, and the crontab invokes it directly, cron gets a permission error it 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 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
> /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:
- The same job installed in two places — a user crontab and an
/etc/cron.d/drop-in, or a config-management-managed file 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 regardless of whether the last one is still running — there is no built-in mutual exclusion.
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.