Cron Job Not Running? A Field-Tested Debugging Checklist
6 July, 2026 DevOps
I once discovered that a production database backup had not run for six weeks. The crontab entry was there. The script worked perfectly when I ran it by hand. Nobody had touched the server. The culprit turned out to be a single unescaped % character in the command — cron had been truncating the command line at that point every single night, silently, while the monitoring dashboard stayed green because nobody had wired the job up to anything that could complain.
Since then I debug cron the same way every time: establish what actually happened first, then follow the evidence. This is that checklist, in the order that finds the problem fastest.
Step Zero: Find Out What Cron Actually Did
Don't guess. Cron logs every job it starts, and the log tells you immediately which of three situations you are in.
# Debian / Ubuntu
grep CRON /var/log/syslog
# RHEL / CentOS / Fedora
cat /var/log/cron
# systemd distributions
journalctl -u cron --since "24 hours ago" # or crond on RHEL
A healthy run looks like this:
Jul 5 03:00:01 web1 CRON[12345]: (deploy) CMD (/usr/local/bin/backup.sh >> /var/log/backup.log 2>&1)
One caveat while reading timestamps: the log is written in the server's timezone, which is often UTC while your head is in local time — the same trap I cover in the Unix timestamp guide. Confirm with timedatectl before deciding a run is "missing".
Now branch:
| What the log shows | What it means | Go to |
|---|---|---|
| No CMD line at the expected time | Cron never started the job | Section 1 |
| CMD line exists, but the work didn't happen | The job started and failed | Section 2 |
| CMD lines at odd times, or doubled | Schedule interpretation problem | Section 3 |
1. Cron Never Started the Job
Is the daemon even running?
Obvious, and still the answer more often than anyone admits — especially on freshly restored servers and minimal containers, where cron is frequently not installed at all.
systemctl status cron # Debian/Ubuntu
systemctl status crond # RHEL
Are you looking at the right crontab?
Every user has a separate crontab, and sudo crontab -e edits root's, not yours. The entry you remember adding may live in a different user's file:
crontab -l # current user
sudo crontab -l # root
sudo crontab -u deploy -l # a specific user
Also remember the format difference: files in /etc/cron.d/ and /etc/crontab require a username field between the schedule and the command; per-user crontabs must not have one. Copy-paste an entry between the two formats and it breaks in both directions — with a user field in a personal crontab, cron tries to execute a command literally named deploy.
The missing trailing newline
Vixie cron historically ignores the last line of a crontab if the file does not end with a newline, and crontab may reject the file outright. If your job is the last line and you edited the file with anything other than crontab -e, add a blank line at the end. This one bug has probably wasted more cumulative hours than any other entry on this list.
The schedule doesn't mean what you think
Two classics:
- Day-of-month and day-of-week combine with OR, not AND.
0 0 15 * 1fires on the 15th and on every Monday — not on Mondays that fall on the 15th. - Impossible dates fail silently.
0 0 30 2 *never fires; there is no February 30th, and cron will not warn you.
Paste the expression into the crontab tester and read the next five fire times in plain English. If they surprise you, the schedule was the bug. The full syntax, including the OR behaviour and the @daily-style shortcuts, is in the cron expressions guide.
Permission gatekeepers
If /etc/cron.allow exists, only users listed in it may use cron at all. If it doesn't but /etc/cron.deny does, listed users are blocked. Rare, but a two-second check on hardened systems.
2. The Job Started but Failed
Cron ran your command; your command didn't do the work. Almost always this is one thing: cron's environment is nothing like your shell.
Reproduce cron's environment
Cron gives your job a minimal environment — no .bashrc, no .profile, PATH reduced to something like /usr/bin:/bin, and sh as the shell rather than bash. "Works in my terminal, fails in cron" is this, nine times out of ten. Capture what cron actually sees, then re-run your command inside exactly that environment:
# 1. Add temporarily, wait a minute, remove:
* * * * * env > /tmp/cron-env.txt
# 2. Reproduce the failure interactively:
env - $(cat /tmp/cron-env.txt) sh -c '/usr/local/bin/backup.sh'
If it fails now, you can debug it live instead of waiting for the next scheduled run. The usual fixes: absolute paths for every binary, an explicit PATH= line at the top of the crontab, and SHELL=/bin/bash if the script relies on bashisms.
The % character
In a crontab command, cron treats every unescaped % as a newline and passes everything after the first one to the command's stdin. The classic victim is date:
# Broken: command is truncated at the %
0 3 * * * tar czf /backups/backup-$(date +%F).tar.gz /var/www
# Fixed:
0 3 * * * tar czf /backups/backup-$(date +\%F).tar.gz /var/www
This is the bug from my six-week backup story. The log shows a CMD line, the exit status is even zero — the command that ran was simply not the command you wrote.
Working directory and permissions
Cron starts jobs in the user's home directory, so relative paths in the script resolve somewhere you didn't intend. And check the boring trio: the script is executable (chmod +x), it has a shebang line, and the cron user can actually read every file the job touches.
Where did the error message go?
By default cron mails job output to a local mailbox nobody has opened since 2009. Until output is redirected somewhere you will actually look, every failure is invisible:
0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
3. Wrong Time, Skipped Runs, or Double Runs
- An hour off, or off by your office's UTC offset — the server timezone isn't what you assumed.
timedatectltells you; scheduling everything in UTC makes the whole class of bug impossible. - A job between 02:00 and 03:00 vanished once in spring, or ran twice one autumn night — daylight saving time. Spring-forward deletes that hour, fall-back repeats it. The timezone section of the cron guide covers the mechanics; the fix is again UTC.
- Runs overlapping — a slow job still running when its next invocation starts. Serialise with
flock:
*/5 * * * * flock -n /tmp/sync.lock /usr/local/bin/sync.sh >> /var/log/sync.log 2>&1
-n makes the second invocation exit immediately instead of queueing up behind the first.
Make the Next Failure Loud
The deeper lesson from my six silent weeks: cron's default failure mode is silence, and a checklist you run after noticing damage is the weakest form of monitoring. Two cheap upgrades:
A dead man's switch. Have the job ping a URL on success; the receiving service alerts you when the ping stops arriving. This catches every failure mode on this page — daemon down, schedule typo, % truncation, crashed script — because it monitors the outcome, not the mechanism:
0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1 && curl -fsS https://hc-ping.com/your-uuid > /dev/null
Timestamped logs you actually rotate. One log file per job, 2>&1, and a logrotate entry. When something does break, Step Zero of this checklist takes thirty seconds instead of an archaeology session.
Set those two up for every cron job that matters, and this article becomes something you read once instead of a page you keep bookmarked.