Guides · Utiltown
Cron Job Not Running? 9 Causes and a Fix Path
"The cron job is not running" is a chain of assumptions, and the break can be in the expression, the timezone, the daemon, the environment, or the command itself. Debugging that chain top-to-bottom finds the break in minutes; poking at the script first usually wastes hours. The nine causes below are ordered by where they sit in the chain — work through them in order and the first failing step is your answer.
Step 0 — Prove the schedule is actually due
Before touching logs, verify the expression means what you think it means in the scheduler you are using. Paste it into the Cron Expression Parser, pick the right dialect, and read the next ten run times. Three mistakes here are near-universal:
- Field-order typos.
0 9 * * *is 09:00 daily;* 9 * * *is "every minute during the 9 o'clock hour" — 60 runs, not one. If the parser's next-run list disagrees with your intention, the schedule was the bug. - The seconds-field shift. A six-field expression is 09:30 in Quartz/Spring/Hangfire (leading seconds), but the same string parses to a different — or invalid — schedule in parsers that read a trailing sixth field as a year. Copying expressions between dialects without re-parsing is how jobs silently stop.
- The day-of-week mapping. In Quartz,
1is Sunday, so0 9 * * 1-5that meant Mon–Fri now includes Sunday and excludes Friday.
Step 1 — Check the timezone the scheduler uses
System cron evaluates the expression in the machine's local timezone, which on cloud servers is usually UTC. Confirm what "now" means to the scheduler:
timedatectl # system timezone
date # what "now" means to cron
TZ=Asia/Shanghai date # what you assume "now" is
Fix options: set CRON=Asia/Shanghai (or
CRON_TZ, depending on the cron implementation) at the top
of the crontab, or schedule everything in UTC and convert at the
presentation layer. GitHub Actions schedules in UTC unless you set the
optional timezone: property on the schedule trigger.
Step 2 — Confirm the trigger fired
grep CRON /var/log/syslog | tail # Debian / Ubuntu
journalctl -u cron --since "1 hour ago" # Debian-family systemd (crond on RHEL)
grep crond /var/log/cron | tail # RHEL / CentOS - Trigger lines exist → the schedule works; the command is failing. Go to step 4.
- No trigger lines → check the daemon
(
systemctl status cron) and what the daemon actually sees:crontab -lfor the user table, plus/etc/cron.d/and/etc/crontabfor system entries. A syntax error in one line can make a crontab partially load.
Step 3 — Check the schedule's edge cases
Some "missing" runs are real schedule semantics, not bugs:
- Day-of-month × day-of-week is OR, not AND. In
standard cron, when both day fields are restricted
(
0 9 13 * 5), the job runs on the 13th or any Friday — union, not intersection. This is POSIX behavior and surprises almost everyone once. Migrating to a scheduler that uses AND instead (Hangfire's Cronos is documented as AND) makes the same expression run far less often — check which semantics your scheduler implements before assuming the union. - February 29 and day-31 expressions simply do not fire in most months.
- DST gaps. A 02:30 schedule does not exist on the spring-forward night in half-year zones — the run is skipped, not delayed.
Step 4 — Reproduce the cron environment
The classic failure: the script runs perfectly in your shell and never
from cron. Cron starts sh with a minimal PATH
(/usr/bin:/bin), no profile, no
nvm/rbenv/pyenv, and no TTY:
# See exactly what cron would resolve
env -i /bin/sh -c 'echo $PATH; which node; which python3' - Use absolute paths for the interpreter and every file it touches.
cdinside the wrapper script — relative paths resolve against the user's home under cron, not the script's directory.- For nvm-managed Node, the crontab line typically needs
. $HOME/.nvm/nvm.sh && node …or the real binary path. - Cron injects only a few variables; anything else (
ENV, credentials,LANG) must be set inside the wrapper.
Step 5 — Capture output; never let it vanish
Without redirection, stdout/stderr go to the local mailbox — and with no MTA configured they are discarded. This is the single most common reason jobs "fail silently for months":
0 9 * * * /opt/scripts/myjob.sh >> /tmp/myjob.log 2>&1
(Use a path the job's user can actually write — a user crontab usually
cannot create files under /var/log; point it at an
app-specific directory you have chown'd.)
Then watch the next run: log created but empty means the wrapper started
and the inner command hangs or exits silently — add set -x
to trace.
Step 6 — Permissions, locking, and overlap
- Ownership: the job runs as the crontab's owner; a root-owned script in a user crontab fails with
Permission denied— visible only in the redirected stderr. - Access control:
cron.allow/cron.denycan silently exclude a user. - Expired password: an expired account password disables all cron execution for that user on some PAM configurations.
- Overlap: a job slower than its interval stacks runs. Serialize with
flock:0 9 * * * flock -n /tmp/myjob.lock /opt/scripts/myjob.sh.
Step 7 — Platform-specific gotchas
| Platform | Symptom | Cause / fix |
|---|---|---|
| GitHub Actions | Runs late by 5–30 min, or skipped | Best-effort scheduling under load. Never for deadlines; use workflow_dispatch from an external timer or a self-hosted runner with real cron. |
| GitHub Actions | Nothing after merging to a branch | schedule: only triggers from the default branch. |
| systemd timer | Timer loaded but never fires | The service unit is not enabled: systemctl enable --now myjob.timer; verify with systemctl list-timers and systemd-analyze calendar. |
| systemd timer | Missed runs while the machine was off | Add Persistent=true to catch up on missed elapses at boot. |
| Quartz (Java) | Fires Sunday instead of Monday | Day-of-week numbering: Quartz 1 = Sunday. Use MON-FRI. |
| Docker | Cron "runs" but nothing happens | Cron drops environment variables set in the image; the daemon is not the container's PID 1 supervisor. Run cron in the foreground and supervise it, or schedule from the host against docker exec. |
| Kubernetes CronJob | Jobs pile up or vanish | concurrencyPolicy defaults to Allow (piles up). Missed runs are re-run after a controller outage when startingDeadlineSeconds is unset (unless 100+ schedules were missed, which aborts with an error) — but explicitly skipped when a deadline is set and exceeded. Set both deliberately. |
| Windows Task Scheduler | Never fires for a different user | Task runs only when the trigger user is logged on — set "Run whether user is logged on or not" (requires stored credentials). |
Step 8 — The 60-second sanity sweep
crontab -l | grep -v '^#' | grep -v '^$' # what is scheduled
systemctl status cron --no-pager | head -3 # is the daemon alive
grep CRON /var/log/syslog | tail -5 # did triggers fire
ls -l /tmp/myjob.log # is output being captured Put the parser in your loop
Most of the chain above reduces to one question: does this expression mean what I think, in this zone, in this dialect? The Utiltown Cron Expression Parser answers it in one paste — plain-English decode, dialect switch, timezone selector, and the next ten run times, all computed locally in the browser.
Frequently Asked Questions
My cron expression parses fine but the job never runs. Where do I start?
Separate the schedule from the execution. First prove the schedule is actually due: validate the expression in the Utiltown Cron Expression Parser, in the right dialect and the server timezone, and compare the computed next-run times with your expectation. Timezone and dialect mistakes look identical to scheduler failures. Only after the times check out should you move to logs, environment, and permissions.
Why does my job run at the wrong time?
Almost always a timezone mismatch. System cron evaluates the expression in the machine timezone (UTC on most cloud servers), GitHub Actions schedules in UTC unless you set the optional timezone property, and Quartz depends on the configured scheduler timezone. Convert your intended local time to the scheduler zone — the Utiltown Unix Timestamp Converter shows both side by side.
The script runs manually but not from cron. What is different?
The environment. Cron starts sh with a minimal PATH (often /usr/bin:/bin), sources no profile, and has no nvm/rbenv/pyenv. The same command line resolves to a different binary — or none. Use absolute paths for interpreters and files, cd inside a wrapper script, and redirect output to a log so the real error is visible.
How do I prove whether cron even fired?
Read the scheduler logs: grep CRON /var/log/syslog (Debian/Ubuntu), journalctl -u cron (Debian-family systemd; the unit is crond on RHEL-family), or /var/log/cron (RHEL family). Trigger lines present means the schedule works and the command is failing; no trigger lines points at the crontab, the daemon, or the expression syntax itself.
Can a cron job fail silently for months?
Yes. Without output redirection, cron mails stdout/stderr to the local mailbox — and with no mail transfer agent configured, that output is discarded. A job that logs nothing may have been failing since day one. End every production job with >> /tmp/myjob.log 2>&1 (a writable path) and alert on the log.