Debugging Cron Jobs That Never Run: A Systematic Checklist
by Sinthuyan Arulselvam · April 10, 2026
You set a cron job. You waited. Nothing happened. You checked the syntax three times. Still nothing. This guide is the checklist you run through when a cron job refuses to fire — written by someone who has debugged this exact problem more times than is healthy. Work through each step in order; most silent cron failures are caught in the first four.
1. Validate the Expression Syntax
Before anything else, confirm the expression itself is valid. Cron syntax has several dialects and the differences matter. A field that's legal in Vixie cron might be silently rejected by another daemon.
Common expression mistakes:
- Day-of-week range is 0–7, where both 0 and 7 mean Sunday. Writing
8in the DOW field is silently ignored on some daemons and causes a parse error on others. - Extra whitespace. Cron fields are separated by a single space. A tab character between fields breaks parsing on some implementations.
- Out-of-range values. Month is 1–12, not 0–11. Day-of-month is 1–31. Hour is 0–23. Minute is 0–59. Using
24for hour or60for minute silently disables the job. - Unquoted percent signs. A bare
%in a cron command is treated as a newline by most cron daemons. Escape it as\%.
Validate manually with a tool before touching anything else:
# Use crontab's own parser — pipe a test entry and check exit code
echo "*/5 * * * * /bin/true" | crontab -
crontab -l If you use CronBase.dev's expression validator, paste the expression and confirm the human-readable translation matches your intent. A mistranslation here (e.g., "every 5th month" when you meant "every 5 minutes") is always a typo in the expression, not the validator.
2. Check the Timezone
Cron evaluates expressions against the system clock. If your server is in UTC and you wrote the job expecting local time, the job fires at the wrong hour — or, near DST transitions, it fires twice or skips entirely.
# What timezone is the cron daemon running in?
date
timedatectl status
# What does the system clock say right now?
date -u # UTC
date # local time (may differ) Most cron implementations support a per-job CRON_TZ variable. Set it at the top of your crontab or inline:
CRON_TZ=America/New_York
0 9 * * 1-5 /usr/local/bin/send-report.sh Systemd timers use OnCalendar with an explicit timezone instead:
OnCalendar=Mon..Fri 09:00 America/New_York When in doubt, convert your intended time to UTC and write the expression in UTC. It removes an entire class of bugs.
3. Verify the Cron Daemon Is Running
A cron job can't run if the daemon is stopped. This is obvious but frequently missed when a server has been rebooted or when an OS upgrade replaced cron with crond (or vice versa).
# Debian/Ubuntu
systemctl status cron
# RHEL/CentOS/Fedora
systemctl status crond
# If stopped, start and enable it
systemctl start cron
systemctl enable cron
# Confirm the process is actually running
pgrep -a cron If systemctl shows the unit as "failed", read the full journal before restarting:
journalctl -u cron --since "1 hour ago" --no-pager Also confirm the right crontab file is being read. crontab -l shows the current user's crontab; system-wide jobs live in /etc/cron.d/, /etc/crontab, or the /etc/cron.{hourly,daily,weekly,monthly}/ drop-in directories.
4. Check Output Redirection
By default, cron mails the output of every job to the user who owns the crontab. If no mail transfer agent (MTA) is installed — common on modern minimal servers — that output is silently discarded. You never see errors, which makes jobs look like they ran successfully even when they crashed immediately.
# Check whether an MTA is installed
which sendmail || which postfix || which exim4
# Is there a mail queue with undelivered cron output?
ls -la /var/spool/mail/
cat /var/spool/mail/$(whoami) The fix: redirect stdout and stderr to a file in the crontab entry itself.
*/5 * * * * /usr/local/bin/myjob.sh >> /var/log/myjob.log 2>&1 To suppress output entirely (only do this once you're confident the job works):
*/5 * * * * /usr/local/bin/myjob.sh > /dev/null 2>&1 Add timestamps to log output so you can correlate entries with cron trigger times:
*/5 * * * * echo "$(date --iso-8601=seconds) starting" >> /var/log/myjob.log && /usr/local/bin/myjob.sh >> /var/log/myjob.log 2>&1 5. File Permissions
A script that your shell runs fine may fail completely under cron due to permission issues. Cron does not run as root by default — it runs as the user who owns the crontab — and it does not inherit your login session's sudo grants.
# Confirm the script is executable
ls -la /usr/local/bin/myjob.sh
# Fix it if not
chmod +x /usr/local/bin/myjob.sh
# Check ownership — cron runs as this user
stat /usr/local/bin/myjob.sh
# Also check every directory in the path
namei -l /usr/local/bin/myjob.sh The namei -l command is invaluable: it walks every component of the path and shows permissions at each level. A directory without execute permission for the cron user stops traversal entirely, and the error is never surfaced unless you've set up output redirection.
If the job reads or writes files (configs, logs, lock files), confirm those paths are also accessible by the cron user:
sudo -u cronuser test -r /etc/myapp/config.yaml && echo "readable" || echo "NOT readable"
sudo -u cronuser test -w /var/log/myapp/ && echo "writable" || echo "NOT writable" 6. Audit Environment Variables
This is the most common cause of jobs that work interactively but fail under cron. Cron provides an extremely minimal environment — no .bashrc, no .profile, no virtual environment activations, no nvm, no pyenv.
# See exactly what environment cron sees
* * * * * env > /tmp/cron-env.txt
# Compare it to your interactive environment
env | sort > /tmp/interactive-env.txt
diff /tmp/interactive-env.txt /tmp/cron-env.txt The default PATH in most cron implementations is:
/usr/bin:/bin That's it. No /usr/local/bin, no /usr/sbin, no user-local paths. Any command not in those two directories will fail with "command not found" — and since you haven't set up output redirection yet (step 4), you'll never see the error.
Solutions, in order of preference:
- Use absolute paths everywhere in the script:
/usr/local/bin/node,/usr/bin/python3,/usr/local/bin/aws. - Set PATH at the top of the crontab (applies to all jobs in that crontab):
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
*/5 * * * * /usr/local/bin/myjob.sh - Source the environment inside the script if you need user-level tooling (use sparingly — this is fragile):
#!/usr/bin/env bash
source /home/deploy/.profile
source /home/deploy/.nvm/nvm.sh
nvm use 20
node /app/worker.js 7. Read the Cron Logs
Cron logs every job invocation. The location varies by distribution:
| Distribution | Log location | Command |
|---|---|---|
| Debian / Ubuntu (syslog) | /var/log/syslog | grep CRON /var/log/syslog |
| RHEL / CentOS / Fedora | /var/log/cron | tail -f /var/log/cron |
| systemd journal (any distro) | journald | journalctl -u cron -f |
| Alpine / BusyBox crond | syslog or stderr | logread | grep crond |
Useful grep patterns to find the job and its result:
# See all cron activity in the last 10 minutes
grep CRON /var/log/syslog | tail -50
# Find CMD lines (actual job executions, not session open/close)
grep "CRON.*CMD" /var/log/syslog
# Watch in real time
tail -f /var/log/syslog | grep --line-buffered CRON A healthy log line looks like this:
Jun 13 08:45:01 hostname CRON[12345]: (deploy) CMD (/usr/local/bin/myjob.sh) If you see (CRON) info (No MTA installed, discarding output) — go back to step 4. If you see no CMD line at all for your job's scheduled time — the daemon isn't picking up the crontab (step 3) or the expression doesn't match (step 1).
8. Run as the Cron User
Reproduce the exact execution context cron uses before filing a bug report or changing anything else. This single step surfaces ~60% of remaining issues.
# Run the command as the cron user, with a minimal environment
sudo -u deploy env -i HOME=/home/deploy \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
SHELL=/bin/sh \
/usr/local/bin/myjob.sh The env -i flag clears all inherited environment variables. What you see here is approximately what cron sees. If the command fails now but works in your normal shell, you've isolated the problem to the environment — most likely a missing PATH entry or a missing environment variable your script depends on.
For jobs in /etc/cron.d/ or /etc/crontab that specify a user in the fifth field, remember to use that user:
# Entry in /etc/cron.d/myapp:
# */5 * * * * www-data /usr/local/bin/myjob.sh
# Reproduce it:
sudo -u www-data env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
/usr/local/bin/myjob.sh 9. Check for Resource Exhaustion
Cron forks a new process for every job. If the system is under resource pressure, the fork silently fails. The cron log may show the CMD line but the job never actually runs to completion.
# Disk full? Cron can't write temp files, logs, or pid files
df -h
du -sh /tmp /var/log /var/spool/cron
# Clean up if needed
journalctl --vacuum-time=7d
find /var/log -name "*.gz" -mtime +30 -delete
# Too many open files?
ulimit -n # current limit
cat /proc/sys/fs/file-max # system-wide max
lsof | wc -l # current open file count
# Memory pressure preventing fork
free -h
vmstat 1 5 # watch for high swap activity
dmesg | grep -i "out of memory" # OOM killer activity If the OOM killer has been active, check which processes it terminated:
dmesg | grep -E "oom_kill|Killed process" | tail -20
journalctl -k | grep -i "oom" | tail -20 A cron job killed by OOM leaves no trace in the cron log — it simply stops mid-execution. Add explicit memory limits using ulimit inside the script or use a systemd timer with MemoryMax= for better control.
10. SELinux and AppArmor
Security modules block execution silently by default. If you're on RHEL/CentOS/Fedora and nothing else explains the failure, SELinux is a serious candidate. On Ubuntu/Debian with AppArmor installed, same story.
# Check SELinux status
getenforce # Enforcing / Permissive / Disabled
sestatus
# Look for recent denials in the audit log
grep -i "denied" /var/log/audit/audit.log | tail -30
ausearch -m avc -ts recent | tail -50
# Generate a human-readable report
sealert -a /var/log/audit/audit.log | head -100 For AppArmor on Debian/Ubuntu:
aa-status
grep "DENIED" /var/log/syslog | grep -i apparmor | tail -20
dmesg | grep -i apparmor | tail -20 Common resolutions:
- SELinux context mismatch: The script has the wrong SELinux file context. Fix with
restorecon -v /usr/local/bin/myjob.shorchcon -t bin_t /usr/local/bin/myjob.sh. - Writing to a restricted path: Move the output file to a path the cron domain is allowed to write, or create a policy module with
audit2allow. - Temporary diagnosis: Set SELinux to permissive mode (
setenforce 0), rerun the job, and confirm it works — then write the proper policy rather than leaving it permissive.
11. Quick Diagnostic Script
When you need to triage a new server fast, run this script as the cron user. It checks the most common failure points in one pass and prints a summary.
#!/usr/bin/env bash
# cron-diag.sh — quick cron environment diagnostic
# Usage: sudo -u <cronuser> bash cron-diag.sh /path/to/script.sh
TARGET="${1:-/bin/true}"
PASS="[PASS]"
FAIL="[FAIL]"
WARN="[WARN]"
echo "=== Cron Diagnostic ==="
echo "Date (local): $(date)"
echo "Date (UTC): $(date -u)"
echo "User: $(whoami)"
echo "Shell: $SHELL"
echo "PATH: $PATH"
echo ""
# 1. Script exists
[ -f "$TARGET" ] && echo "$PASS Script exists: $TARGET" \
|| echo "$FAIL Script not found: $TARGET"
# 2. Script is executable
[ -x "$TARGET" ] && echo "$PASS Script is executable" \
|| echo "$FAIL Script is NOT executable — run: chmod +x $TARGET"
# 3. Disk space
DISK_USE=$(df / | awk 'NR==2{print $5}' | tr -d '%')
[ "$DISK_USE" -lt 90 ] && echo "$PASS Disk usage: ${DISK_USE}%" \
|| echo "$FAIL Disk usage critical: ${DISK_USE}%"
# 4. Memory
FREE_MB=$(free -m | awk '/^Mem/{print $4}')
[ "$FREE_MB" -gt 50 ] && echo "$PASS Free memory: ${FREE_MB}MB" \
|| echo "$WARN Low free memory: ${FREE_MB}MB"
# 5. Cron daemon running
if systemctl is-active --quiet cron 2>/dev/null || systemctl is-active --quiet crond 2>/dev/null; then
echo "$PASS Cron daemon is running"
else
echo "$FAIL Cron daemon is NOT running"
fi
# 6. SELinux status
if command -v getenforce &>/dev/null; then
SE=$(getenforce)
[ "$SE" = "Permissive" ] || [ "$SE" = "Disabled" ] \
&& echo "$WARN SELinux: $SE (not enforcing)" \
|| echo "$PASS SELinux: $SE"
fi
# 7. Path check for common tools
for cmd in bash python3 node; do
command -v "$cmd" &>/dev/null \
&& echo "$PASS $cmd found at $(command -v $cmd)" \
|| echo "$WARN $cmd not in PATH"
done
echo ""
echo "=== Running target with minimal env ==="
env -i HOME="$HOME" PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
SHELL=/bin/sh bash -c "$TARGET" && echo "$PASS Command exited 0" \
|| echo "$FAIL Command exited non-zero (exit code: $?)" Save it to /usr/local/bin/cron-diag.sh, make it executable, and run it as the cron user against your failing script:
chmod +x /usr/local/bin/cron-diag.sh
sudo -u deploy bash /usr/local/bin/cron-diag.sh /usr/local/bin/myjob.sh Summary Checklist
| # | Check | Quick command |
|---|---|---|
| 1 | Expression valid | Validate on CronBase.dev |
| 2 | Timezone correct | timedatectl status, set CRON_TZ |
| 3 | Daemon running | systemctl status cron |
| 4 | Output redirected | Add >> /var/log/job.log 2>&1 |
| 5 | File is executable | namei -l /path/to/script.sh |
| 6 | Env vars set | Set PATH in crontab, use absolute paths |
| 7 | Logs reviewed | grep CRON /var/log/syslog |
| 8 | Run as cron user | sudo -u user env -i ... script.sh |
| 9 | Resources available | df -h, free -h, dmesg | grep oom |
| 10 | SELinux/AppArmor | grep denied /var/log/audit/audit.log |
| 11 | Run diagnostic | sudo -u user bash cron-diag.sh /path/script.sh |
Work through these in order. The majority of silent cron failures are resolved by step 6. The diagnostic script in step 11 gives you a one-shot view when you're new to a server and need answers fast. If you've completed every step and the job still won't run, the problem is almost certainly inside the script itself — run it manually as the cron user with env -i and watch the output line by line.