The backup didn’t run. The log file is empty. The scheduled post never published. You run crontab -l, the line is sitting right there, correct, and nothing happened.
Cron itself almost never breaks. What breaks is the environment cron hands your script, and cron tells you nothing about it: no error, no notification, no exit status anywhere you would look.
Check the obvious three first — daemon running (systemctl status crond, or cron on Debian/Ubuntu), exactly five time fields before the command (or one of the @reboot / @daily / @hourly shorthands), and the script readable by the crontab owner, plus executable if you invoke it directly rather than through an interpreter. If those are fine, the problem is environmental, which is the rest of this article.
The one command that reproduces most cron failures
Cron gives your script a stripped environment, not your login one. Build that environment by hand and watch the command fail in front of you:
env -i PATH=/usr/bin:/bin HOME=$HOME SHELL=/bin/sh /bin/sh -c '/full/path/to/your/command'
Run it as the account user, not as root. If the command works when you type it normally and fails here, you found the bug in four seconds — and it was never a cron bug.
Why “it works when I run it over SSH” is not evidence
On shared cPanel hosting two independent mechanisms stack, and either alone is enough to make a working command fail from cron. What follows was checked on a production CloudLinux/cPanel shared node in August 2026 — one box, not a fleet survey.
Mechanism A: cron’s PATH is not your login PATH
Cronie’s compiled-in default is PATH=/usr/bin:/bin, and unless the daemon is started with -P — which makes children inherit the system PATH instead — that is what your job gets. /usr/local/bin is not in that list. One omission, two failures that look nothing alike.
The loud one. WP-CLI installs to /usr/local/bin/wp, so under cron you get wp: command not found — while the same line pasted into SSH works perfectly.
The quiet one, which is worse. A bare php does resolve under cron’s PATH — to a different binary than your shell finds.
| Command | Interactive SSH | Cron (PATH=/usr/bin:/bin) |
|---|---|---|
php |
/usr/local/bin/php (SAPI: cli) |
/usr/bin/php (SAPI: cgi-fcgi) |
wp |
/usr/local/bin/wp |
Not found |
mysqldump, curl, wget |
/usr/bin/... |
Same — these work fine |
/usr/local/bin/php is cPanel’s ea-php-cli wrapper; it resolves the per-domain version through MultiPHP. /usr/bin/php on a stock EasyApache 4 box is the CGI handler binary and reports SAPI cgi-fcgi. On accounts using CloudLinux PHP Selector it is different again — inside CageFS it symlinks to that user’s selected alt-php build — so what /usr/bin/php means varies per account. Either way a different SAPI means a different php.ini, different extensions, different max_execution_time handling, and CGI-style headers leaking into output. The script runs and exits zero, so nothing upstream notices.
Never write a bare command name in a cron line.
# Wrong - resolves to a different PHP than your SSH session
0 3 * * * php /home/USER/public_html/script.php
# Right - explicit interpreter, explicit version
0 3 * * * /opt/cpanel/ea-phpXX/root/usr/bin/php /home/USER/public_html/script.php
# Or set PATH at the top of the crontab
PATH=/usr/local/bin:/usr/bin:/bin
Swap ea-phpXX for the version the domain runs; ls /opt/cpanel/ lists what is installed.
One thing an absolute path does not fix: a shebang of #!/usr/bin/env interpreter, because env resolves that interpreter through PATH — cron’s PATH. WP-CLI is the standard case: /usr/local/bin/wp is wp-cli.phar with #!/usr/bin/env php on line one, so even an absolute path to wp lands on /usr/bin/php underneath. Name the interpreter yourself, or set PATH in the crontab.
Another trap: /etc/crontab and files in /etc/cron.d/ ship their own PATH= line, and it is not the user-crontab default. On RHEL-family systems including CloudLinux it is PATH=/sbin:/bin:/usr/sbin:/usr/bin — /usr/local/bin is still missing, though /sbin and /usr/sbin are added. Debian and Ubuntu do include /usr/local/bin. Test where the job actually lives.
Mechanism B: your cron job runs inside CageFS. Your root SSH session does not.
On CloudLinux, PAM places every cron job owned by a normal user account into that account’s LVE container and into CageFS before the command runs. The job executes in a different filesystem namespace than the root SSH session an administrator tests it from.
Inside the cage the account sees a curated subset of the filesystem, not the whole box. A binary exists there only if the CageFS configuration names it, and much of /usr/bin is not named — interpreters and transfer tools an admin takes for granted are commonly absent. Check from inside: cagefsctl --enter USER as root drops you into that account’s view, and command -v python3 there is the only answer that counts.
A helper script written into /tmp as root, then invoked as the account user:
/bin/bash: /tmp/probe.sh: No such file or directory
The file was plainly there; ls /tmp as root showed it. The user’s process could not, because CageFS gives every account a private /tmp. That is the shape of a whole class of ticket: “my cron script writes a lock file to /tmp and the next run never finds it.”
On shared cPanel hosting, /tmp is per-account. Never use it as a handoff point. Put lock files, temp files and logs inside the account’s home.
Cron has no overlap protection. None.
This is the belief that causes the most damage, because it is the opposite of the truth. Cron does not skip a run because the previous one is still going, and it does not queue. It forks a new process every time the schedule matches, with no knowledge of whether the last copy is alive.
We tested that on a live cron daemon: a * * * * * entry pointing at a script that logs its start time and sleeps 150 seconds.
07:12:01 START
07:13:01 START
07:14:01 START
07:14:09 - all three copies still alive
Left alone, a job taking ten minutes on a one-minute schedule accumulates roughly ten concurrent copies, forever, until memory, a process limit or the database gives out.
This is the entire reason flock exists. CloudLinux does not trust cron to self-serialize either; its own system crons appear in the log wrapped in it:
CROND[12345]: (root) CMDEND (/usr/bin/flock -n /var/run/...cronlock /usr/sbin/...)
Do the same:
* * * * * /usr/bin/flock -n /home/USER/tmp/myjob.lock /home/USER/scripts/myjob.sh >> /home/USER/logs/myjob.log 2>&1
flock -n means “fail immediately rather than wait.” Without -n the blocked copies queue up instead, which is usually also not what you want. It is silent when it skips — exit 1, no output — so an empty log does not mean the wrapper is broken; add --verbose to record the skip. Create the lock directory first, in the account’s home rather than /tmp, and confirm the binary is reachable from where the job runs with command -v flock.
Every minute is almost never the right answer
Because CloudLinux places cron jobs inside the account’s LVE, a cron job is not free — it draws on the same container limits as live web requests, including the entry-process limit. Combine that with the overlap behaviour above. A * * * * * job taking 90 seconds permanently holds about two concurrent slots; one taking ten minutes holds about ten. When the entry-process limit is reached, further processes are refused and visitors get a 508 Resource Limit Is Reached page. That is the usual shape of a “my whole site is down” ticket that turns out to be a backup script on a one-minute schedule.
- Match the interval to the work. Backup: daily. WP-Cron: every 15 minutes. Feed import: hourly. Reserve every-minute for something that needs it and finishes in seconds.
- Wrap it in
flock -nso a slow run becomes a skipped run instead of a rolling outage. - Stagger the minute. Everyone writes
0 3 * * *, so every account on the server fires at once. Use17 3 * * *.
WordPress: wp-cron.php and the double-fire trap
WP-Cron is not cron. There is no daemon and no timer. WordPress keeps a queue of due tasks in the cron row of wp_options and, on each incoming page request, checks whether anything is overdue. If so it fires a loopback request to /wp-cron.php. Traffic-driven, not time-driven.
- Low-traffic sites silently stop. No visitors, no requests, no trigger. Backups and scheduled posts stop. Nothing errors, nothing logs.
- High-traffic sites do the opposite. Every hit pays the overdue check, and bursts fire overlapping loopbacks — a direct entry-process consumer.
- Caching and some security plugins block the loopback, so even a busy site can have a dead WP-Cron.
Now the trap. The customer reads a tutorial, adds a real cPanel cron job hitting wp-cron.php, and does not disable the built-in pseudo-cron. Both now fire. WordPress’s doing_cron transient lock (WP_CRON_LOCK_TIMEOUT, 60 seconds by default) usually stops the same task executing twice, but it is best-effort, not a guarantee — on busy sites duplicate runs do happen. What is guaranteed is the wasted work: two independent triggers, doubled loopback requests, doubled entry-process consumption. Disable the built-in one in wp-config.php, above the “stop editing” line:
define('DISABLE_WP_CRON', true);
Then add one real cron job. WP-CLI is best — no HTTP round trip — but remember the shebang problem, so name the interpreter instead of relying on cron’s PATH:
*/15 * * * * /opt/cpanel/ea-phpXX/root/usr/bin/php /usr/local/bin/wp --path=/home/USER/public_html cron event run --due-now >> /home/USER/logs/wp-cron.log 2>&1
Confirm WP-CLI is actually installed and reachable from the account first (command -v wp over SSH as the account user). If it is not, use the loopback URL:
*/15 * * * * /usr/bin/curl -sS -o /dev/null 'https://example.com/wp-cron.php?doing_wp_cron' >> /home/USER/logs/wp-cron.log 2>&1
Quote the URL: cron runs commands through /bin/sh, where ? and & are metacharacters. And keep ?doing_wp_cron with no value — wp-cron.php treats an empty value as an external invocation and takes its own lock, whereas ?doing_wp_cron=1 makes the lock comparison fail and the script exits without doing anything.
Fifteen minutes is the sane default; every minute is not more reliable, only more load. Avoid the wget -q -O - pattern tutorials hand out: -O - writes the response body to stdout and cron mails anything on stdout. wp-cron.php itself returns an empty body, so that one case gets away with it, but any PHP notice becomes an email. Use -O /dev/null regardless. To check the queue is alive, run wp cron event list; deeply negative next-run values mean it is dead.
Where cron’s output goes, and why the email vanished
Three flat cases. No MAILTO line: output goes to the crontab owner. MAILTO=someone@example.com: it goes there. MAILTO="": nothing is sent. cPanel exposes this as the “Email” field on the Cron Jobs page. Any output triggers a mail, not just errors — one stray echo, one PHP notice, one wget -O - dumping an HTML page. That is the origin of “my cron job spams me every five minutes.”
The consequential half: if the domain’s mail routing is set to Remote — Google Workspace, Microsoft 365, mail anywhere else — the output leaves the server. cPanel’s Exim decides local versus remote from /etc/localdomains and /etc/remotedomains; a domain is in one list or the other, never both.
For a remote-routed domain, cron’s output becomes ordinary outbound internet mail: machine-generated, from a bare Unix account name, leaving a shared hosting relay. It gets spam-foldered, rejected because username@domain.com is not a real mailbox at the new provider, or dropped on DMARC. The customer concludes the job never ran. It ran fine; the receipt was lost in the mail. So do not rely on cron mail at all:
MAILTO=""
0 3 * * * /home/USER/scripts/backup.sh >> /home/USER/logs/backup.log 2>&1
Keep the log in the account’s home — not /tmp (private per account) and not /var/log (a cPanel user cannot write there). Then rotate it, or in six months it becomes the disk-full ticket.
One correction to advice you will see repeated: a missing MTA does not hang a cron job. Cron reads your job’s output as it runs and forks the local mailer on the first byte. If no mailer exists that fork fails and the output is discarded; execution continues either way. A wedged or very slow mailer is a different matter — cron stops draining your stdout, the pipe fills, and your job blocks on write. One more reason to redirect to a file yourself and set MAILTO="".
Reading the cron log
On RHEL-family systems including CloudLinux, rsyslog writes cron activity to /var/log/cron while systemd separately journals it. These are not equivalent. /var/log/cron is a real file on disk. The journal is durable only if /var/log/journal exists; if it does not, journald is volatile under /run and every reboot wipes the history, however healthy journalctl --disk-usage looks beforehand.
So journalctl -u crond --since "1 hour ago" answers “did it fire recently,” and grep CRON /var/log/cron answers “why did the 3 AM job fail last Tuesday.” Both are root-only. And rsyslog’s /var/log/messages rule excludes the cron facility, so people grep the wrong file and conclude cron never ran. Real lines look like this:
Aug 7 10:13:01 host CROND[12345]: (user) CMD (/path/to/script.sh >/dev/null 2>&1)
Aug 7 10:13:01 host CROND[12345]: (user) CMDEND (/path/to/script.sh >/dev/null 2>&1)
You get start and finish events, the exact command line, and the owner. You get no output — and an exit status only sometimes: when a job exits non-zero and cron has no way to mail the result, it logs (CRON) error (grandchild #NNN failed with exit status N). Grep for that too; it is the only exit-code signal cron gives you, and MAILTO="" is exactly the setup that produces it. Otherwise /var/log/cron answers one question: did cron fire the job? If the entry is there, cron did its part and the bug is in your script or its environment — which is why redirecting to your own log file is not optional. (On Debian and Ubuntu the daemon is cron and the log is /var/log/syslog.)
The ordinary causes, quickly
- Timezone. Cron uses the server’s local zone. If the server is UTC and you assumed Eastern, your 3 AM job runs at 11 PM the previous evening. Check
timedatectl, or pin it withCRON_TZ=America/Torontoat the top of the crontab. Cronie compensates for daylight saving on fixed-time jobs — runs skipped by the spring-forward jump execute right after it, and the fall-back duplicate is suppressed — but jobs pinned withCRON_TZor running every minute get no such handling. - Unescaped percent signs. The first unescaped
%ends the command; everything after it is handed to the job on standard input, with further%characters turned into newlines. Sodate +%Y%m%dsilently runs asdate +withY%m%dpiped in. Writedate +\%Y\%m\%d. - Missing user field. In
/etc/crontaband/etc/cron.d/*the sixth field is the username, not the command. - Permissions, disk, inodes. The job runs as the crontab owner, who needs read on the script (execute too, if it is invoked directly) and write on any output directory. Check
df -handdf -i; inodes exhaust independently of bytes. If it still will not run, check SELinux or AppArmor.
Diagnostic order
| Step | Check | If yes | If no |
|---|---|---|---|
| 1 | Cron daemon running? | Go to 2 | systemctl enable --now crond |
| 2 | /var/log/cron shows a CMD line for it? (root only) |
Cron fired it. Skip to 4 | Go to 3 |
| 3 | Five fields, right timezone, right crontab owner? | Go to 4 | Fix the schedule; check timedatectl |
| 4 | Fails under env -i PATH=/usr/bin:/bin ... as the account user? |
PATH or environment. Absolute paths, name the interpreter | Go to 4b |
| 4b | Job touches /tmp, python3, rsync, or anything outside the account home? |
Suspect CageFS. Re-run inside cagefsctl --enter USER — env -i will not show you this |
Go to 5 |
| 5 | Capturing output to your own log file? | Read the log for the real error | Add >> /home/USER/logs/job.log 2>&1, wait one cycle |
| 6 | Multiple copies alive at once? | Wrap in flock -n, lengthen the interval |
Check disk, inodes, SELinux |
Related guides
- Get Automatic Disk Alerts via Email Using Linux — a cron job worth having
- Your Linux Server Disk Is Full: A Systematic Guide — what unrotated cron logs cause
- VPS Security Hardening in 30 Minutes — permissions and filesystem fundamentals
/var/log/cron is readable only by root, so on shared hosting the one question you cannot answer yourself is whether cron fired the job at all. That is worth a ticket rather than another hour of guessing: send your host the exact crontab line, the account it belongs to, and the times you expected it to run. On a VPS, Cloud or dedicated server that log is yours to read — and if you would rather someone else read it, that is what Managed Hosting Support is for.
Be First to Comment