Cron Jobs Not Running: A Practical Troubleshooting Guide
The report was supposed to arrive before the team's first meeting. It didn't. The queue is growing, the data is stale, and someone insists the cron job “just stopped running” even though the server itself looks healthy.
That symptom sends operators toward the wrong question. A cron job may be missing from the scheduler, starting with the wrong environment, blocked by permissions, running on a host that no longer exists, or completing with an error that nobody receives. Cron has been evolving across Unix implementations for decades, and its behavior depends on the daemon, crontab format, user context, operating system, and logging path, not on one universal failure mode (cron's Unix history).
Table of Contents
- Why Cron Jobs Fail Without a Trace: A Four-Question Framework
- Verifying Crontab Syntax and Schedule
- Fixing Environment, PATH, and Permission Issues
- Diagnosing Daemon, systemd, and Log Visibility
- Container, SELinux, and Ephemeral-Host Edge Cases
- Overlap, Runtime Windows, and Silent Exit Codes
- Monitoring Cron Health and Closing the Loop
Why Cron Jobs Fail Without a Trace: A Four-Question Framework
A production operator usually finds the problem through a downstream symptom. A report is missing, a queue grows, a backup file stays old, or customers receive stale information. Cron may have loaded the entry and started the process, while the actual task failed inside a constrained execution environment.
Cron is an unattended batch executor. It launches a command on schedule with a limited runtime context, then exposes only the output supported by the host's logging and mail configuration. Missing interpreters, unexpected working directories, unavailable credentials, and dependencies that reject the cron user can all stop a task. Without explicit output capture, the useful evidence may disappear when the child process exits.
Containerized and ephemeral hosts add another failure mode. The machine that carried the crontab may be replaced, the container may stop before its schedule fires, or several replicas may run the same entry. A valid schedule on a host that no longer exists produces no useful work.
Practical rule: Treat every cron entry as production software with an owner, an exit status, and an observable success signal.

Cron logs can confirm that a job started or that the daemon reported an error. They cannot always confirm end-to-end completion. Monitoring history addresses that gap by recording execution outcomes, including successful and failed runs, so operators can identify missed executions that daemon logs alone may not reveal (cron log visibility and monitoring history).
Use four questions to narrow the failure:
- Was the entry loaded? Confirm the intended user or system file contains the job.
- Did the daemon attempt it? Check service status and scheduler records.
- Could the command run? Verify paths, permissions, interpreters, credentials, and working directories.
- Did the task deliver its result? Application logs or external monitoring must confirm this.
A missing report is an outcome, not a diagnosis. Classifying the failure first keeps investigation focused on the checks that shorten resolution time.
Verifying Crontab Syntax and Schedule
Start with the crontab, not the application. Run crontab -l as the user who owns the job. If the entry isn't there, checking Python, database credentials, or file permissions is wasted effort. A deleted account, a replaced spool directory, or an edit that was never saved can remove the schedule without changing the script itself.
A user crontab uses five time fields followed by a command:
minute hour day-of-month month day-of-week command
For example:
30 2 * * * /usr/local/bin/backup.sh
*/5 * * * * /usr/local/bin/health-check.sh
Read the expression carefully. A weekday can be represented in more than one accepted way on common implementations, and day-of-month combined with day-of-week has an important OR behavior, not the AND behavior many operators expect. For unusual expressions, compare the result with the crontab(5) manual or a schedule parser before deploying it.
System crontabs have a different shape
Files under /etc/cron.d/ include a user field that user crontabs omit:
30 2 * * * backup /usr/local/bin/backup.sh
A malformed system entry can be ignored even though the command itself is valid. The file also needs a terminating newline. This is a particularly unpleasant trap because an editor, deployment template, or configuration-management task can remove that final character and leave a file that looks correct in a visual review.
Check access controls as well:
cat /etc/cron.allow 2>/dev/null
cat /etc/cron.deny 2>/dev/null
An allowlist can prevent a user from installing or managing cron entries. The exact behavior differs between distributions, so the files must be interpreted alongside the local crontab(5) documentation.
Prove the entry before trusting the cadence
Run the command manually under the intended user, using its full path. Then create a temporary test entry that writes a timestamp to a known file during a short test window:
* * * * * /usr/bin/date >> /tmp/cron-test.log 2>&1
If that test doesn't create output, the problem is below the application layer. If it does, restore the schedule and investigate the command's execution environment.
Fixing Environment, PATH, and Permission Issues
A command that works in an SSH session can fail under cron because the two executions aren't equivalent. Interactive shell startup files such as .bashrc and .profile aren't automatically loaded, aliases aren't available, there's no terminal, and environment variables supplied by a login session may be absent. The job also may start from an unexpected working directory.
The safest response is to make dependencies explicit. Set the shell and path near the top of the crontab:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Use absolute paths for interpreters and executables:
15 2 * * * /usr/local/bin/backup.sh >> /var/log/backup-cron.log 2>&1
Inside the script, specify the interpreter with a valid shebang, change to a known directory, and fail when a required command fails. A Bash wrapper might begin like this:
#!/bin/bash
set -euo pipefail
cd /opt/myapp
/usr/bin/python3 /opt/myapp/process.py
The guide to executing scripts safely is useful when the failure involves interpreter selection, executable status, or user context rather than scheduling.
Reproduce the constrained context
The strongest test is not “does it work for the administrator?” It's “does it work for the cron user with a minimal environment?”
sudo -u www-data /usr/local/bin/backup.sh
sudo -u www-data /bin/bash -c 'cd /opt/myapp && /usr/bin/python3 /opt/myapp/process.py'
env -i HOME=/home/user LOGNAME=user PATH=/usr/bin:/bin SHELL=/bin/sh \
/usr/local/bin/backup.sh
Check every directory in the path, not just the final script:
namei -l /usr/local/bin/backup.sh
ls -l /usr/local/bin/backup.sh
The cron user needs permission to traverse parent directories, read the script, execute it, and write to its destination. A script may also fail because it expects a keyring, an interactive prompt, standard input, or a credential injected only into a login session.
The command line is not the runtime contract. The cron entry, user, shell, path, directory, permissions, and output destination are the contract.
A systemd service can add another layer of isolation. Some distributions or unit configurations use private temporary directories or filesystem protection, so a script that writes to /tmp or another protected location may behave differently under the service than it does in a shell. Inspect the service unit and its effective sandbox settings instead of assuming the daemon has unrestricted access.
For directory-based jobs, test discovery explicitly:
run-parts --test /etc/cron.daily
During debugging, redirect both standard output and standard error to a durable, writable log. Cron mail is useful only when local mail delivery is configured and someone reads it.

Diagnosing Daemon, systemd, and Log Visibility
Once the entry and command look correct, verify the scheduler itself. On Debian and Ubuntu, inspect cron; on systems using the alternative service name, inspect crond:
systemctl status cron
systemctl status crond
systemctl is-enabled cron
systemctl is-enabled crond
Only one service name may exist, so a “unit not found” result isn't automatically an outage. The useful question is whether the expected daemon is active, enabled for boot, and free from restart failures.
Use the journal before searching every system log:
journalctl -u cron -u crond --since "1 hour ago"
On Debian and Ubuntu, /var/log/syslog may also contain scheduler activity:
grep CRON /var/log/syslog
A command entry in the log means the daemon attempted to launch it. It doesn't prove that the script completed successfully. No command entry generally means the daemon didn't reach that job, so return to the schedule, loaded-file, permission, timezone, and service checks.
Increase signal during a live incident
Debian and Ubuntu installations can support more verbose cron logging through the distribution's cron configuration, with syslog capture forwarding the result to a dedicated file such as /var/log/cron.log. The exact flag and configuration path depend on the installed cron implementation, so verify the local package documentation before changing service arguments.
Reproduce the command as the actual service user:
runuser -u www-data, /usr/local/bin/backup.sh
That single comparison often divides the incident cleanly. If the user-level invocation fails, the daemon is probably innocent. If it succeeds but the scheduled attempt never appears, focus on loading, timing, access controls, and service state.
For timer-based deployments, inspect systemd timers too:
systemctl list-timers --all
A masked or inactive timer can look like a broken cron job when the host has moved to a different scheduling mechanism. For workloads that must run after a machine has been offline, anacron or an external scheduler may fit better than classic cron, which evaluates schedules only while its host and daemon are available.
The complete logrotate guide helps when evidence exists but is being rotated, truncated, or written somewhere operators aren't checking.

A concise command walkthrough can reinforce the service-first sequence:
Container, SELinux, and Ephemeral-Host Edge Cases
Many investigations fail because they assume the scheduler exists in the runtime being inspected. A slim or distroless container may contain the application but no cron, crond, or anacron. A container image can also include the package while its entrypoint starts only the application process, leaving the scheduler never launched.
Check the runtime directly:
which cron
which crond
ps -ef
Then determine where scheduling is meant to happen. Is cron PID 1, a sidecar, a host service, a Kubernetes controller, or an external managed scheduler? If the job file exists only in a build layer that a deployment no longer uses, the operator may be checking the right path in the wrong image.
Kubernetes introduces different controls. A CronJob can be present while a prior execution remains active, a concurrency policy prevents a new run, or the controller is operating in a different namespace. Inspect the resource and its recent jobs:
kubectl get cronjobs -o wide
kubectl describe cronjob <name>
kubectl get jobs
Ephemeral hosts create a more fundamental problem. An autoscaled, spot, or short-lived machine can terminate before its local schedule arrives, and a CI runner may disappear between pipeline phases. In that model, adding more daemon logging won't create durable scheduling. An external trigger or managed scheduler is usually the appropriate execution model.
Security controls can produce the same symptom:
ausearch -m avc -ts recent
aa-status
dmesg | grep -i apparmor
Review labels, policies, denied file access, network egress, and write locations instead of disabling enforcement as a permanent fix. Timezone differences deserve equal attention. Compare the host timezone and clock with the operator's expectation, particularly after image changes or host migration.
The SELinux versus AppArmor comparison provides useful context when the daemon records an attempt but the process cannot access its dependencies.
Where Cron Jobs Die Silently
| Environment | Symptom | Verification command |
|---|---|---|
| Traditional host | Entry exists, but no execution line appears | systemctl status cron or systemctl status crond |
| Container | Application runs, scheduled task never appears | which cron; which crond; ps -ef |
| Kubernetes | New run doesn't start after an earlier run | kubectl describe cronjob <name> |
| Ephemeral host | Job works only when a particular instance survives | Check host lifecycle and scheduler ownership |
| SELinux or AppArmor | Manual run succeeds, scheduled run lacks access | ausearch -m avc -ts recent, aa-status |
| Timezone mismatch | Job runs at an unexpected local time | Compare host timezone with the intended schedule |
The under-covered diagnosis is often not “cron is broken.” It's “the job is executing in a namespace, image, host, or timezone nobody intended.”
Overlap, Runtime Windows, and Silent Exit Codes
A job can be running while its users see no useful result. That distinction matters when a workload grows, a downstream service slows, or the previous invocation remains active when the next schedule arrives.
Cron itself doesn't provide application-level serialization. Without a lock, repeated triggers can create overlapping processes, duplicate work, lock contention, resource pressure, or competing writes. Before changing the schedule, inspect running processes and elapsed time:
ps -eo etime,pid,cmd
Use a non-blocking lock when only one invocation should run:
flock -n /var/lock/backup.lock \
/usr/local/bin/backup.sh >> /var/log/backup-cron.log 2>&1
A lock changes the failure mode from uncontrolled overlap to an explicit skipped attempt, which is easier to observe and reason about. The wrapper should log when it declines to start, not merely exit without a trace.
Separate trigger failure from work failure
A useful wrapper records start time, end time, and exit status:
#!/bin/bash
set -u
log=/var/log/backup-cron.log
{
date
/usr/local/bin/backup.sh
status=$?
printf 'exit_code=%s\n' "$status"
exit "$status"
} >> "$log" 2>&1
The exit code must reach a notification path. Cron's default mail behavior is not a reliable alerting system when mail delivery isn't configured, stderr is suppressed, or nobody monitors the mailbox. A visible logfile, structured event, or external heartbeat gives operators evidence that the process finished and whether it succeeded.
Runtime problems and exit-code problems need different remedies:
- Long runtime: serialize with
flock, split the workload, or move execution to a queue or workflow system. - Nonzero completion: inspect the failing dependency, preserve stderr, and add deliberate retry behavior.
- Intermittent dependency failure: retry with backoff and jitter, while imposing a maximum attempt count.
- Duplicate side effects: make the operation idempotent before permitting retries or overlap.
Cron will launch according to its schedule. It won't understand whether a prior run is still processing, whether a retry is safe, or whether the result is complete. Those decisions belong in the job or in a scheduler designed to model them.

A missing output file doesn't prove a missing trigger. Check process overlap, exit status, and downstream completion before rewriting the schedule.
Monitoring Cron Health and Closing the Loop
Syslog answers a narrow question: did the daemon attempt to start a command? Production operations need a broader signal: was the expected run received, did it finish, how long did it take, and was the result usable?
A heartbeat closes that gap. The script can perform its work, verify the outcome, and then call a unique monitoring endpoint only after success. If the endpoint doesn't receive the expected ping within the configured window, the monitor can alert on a missed or late run rather than waiting for a person to notice stale data.
A practical wrapper should also preserve local evidence:
#!/bin/bash
set -euo pipefail
log=/var/log/report-cron.log
{
date
/usr/local/bin/generate-report
curl --fail --silent --show-error \
} >> "$log" 2>&1
The heartbeat must follow the meaningful success condition. Sending it before the report is written creates a false green status. Teams that need a lightweight guide to readable operational records can use these practical logging tips for small teams when choosing log destinations, retention, and escalation paths.
Dedicated cron monitoring records expected versus actual execution windows and can expose a daemon stall before users report a missing result. Fivenines provides cron job tracking through a unique ping URL, with alerts for failed, late, long-running, or missed scheduled tasks, alongside infrastructure and uptime monitoring. Its alert workflows can route missed-run events through notification channels and escalation rules, which is more actionable than searching syslog after an incident.
Production cron hygiene is straightforward:
- Use absolute paths: Don't depend on an interactive shell.
- Capture stdout and stderr: Preserve the first useful failure clue.
- Return meaningful exit codes: Make success and failure machine-readable.
- Prevent unsafe overlap: Use locks or a scheduler with concurrency controls.
- Verify completion: Send a heartbeat only after the task succeeds.
- Alert externally: Configure cron and infrastructure alerts so a missed run reaches the team without relying on local mail.
A cron job should never be considered healthy merely because its line remains in a crontab. Health means the expected execution occurred, the command completed, and an independent signal confirms the result.
Fivenines tracks scheduled tasks through success pings and alerts when a cron job is late, too slow, failed, or missed, so teams can stop discovering incidents through stale reports. Visit Fivenines to connect cron health with server, container, and uptime monitoring in one operational dashboard.