# Zactonz Cron Doctor

> See whether your cron jobs really run, and fix the ones that do not.

- **Type:** cPanel plugin (cPanel & WHM)
- **Version:** 1.0.1, released 5 Oct 2026
- **Requires:** cPanel 102+ (Jupiter), Root to install, PHP 7.4+
- **Licence:** Free & open source
- **Download:** https://github.com/zactonz/cron-doctor/releases/latest/download/cron-doctor.tar.gz
- **Source:** https://github.com/zactonz/cron-doctor
- **Documentation:** https://developers.zactonz.com/cpanel-whm/cron-doctor/

## Overview

cPanel gives you a raw crontab and mails you whatever a job prints. That is enough to add a job and not much else. It will not tell you that a job has been failing since March, that two copies are running on top of each other, that a job calls `/usr/bin/php` instead of the PHP version the site is set to, or that WordPress cron is being triggered twice.

Cron Doctor wraps each job you choose in a small runner that takes a lock, applies an optional time limit, captures the output and records the exit code and duration. It then shows the last run, alerts you when a job fails or does not run at all, and offers one-click fixes for the common mistakes. The original crontab line is kept, so monitoring can always be undone.

### What it does

- **Records every run** of a monitored job: exit code, duration and output, with a size limit.
- **Stops overlapping runs.** A job will not start while the previous run is still going. Skipped runs are recorded, so an overlap is visible rather than silent.
- **Mails you only when something is wrong.** Cron normally mails you after every run that prints anything. A monitored job stays quiet on success and reports its captured output when it fails.
- **Notices when a job has not run at all**, which cron itself can never tell you, and emails you about it.
- **Fixes the PHP path.** It finds jobs calling `php` or `/usr/bin/php` and offers to switch them to the EasyApache binary the site is actually set to use.
- **Finds the usual problems**: duplicated WordPress cron, WordPress still running its own scheduler alongside a real cron job, scripts that no longer exist, output thrown away to `/dev/null`, an empty `MAILTO`, Windows line endings, passwords on the command line, and jobs that take nearly as long as the gap between runs.
- **Keeps a way back.** The crontab is backed up before every change, every change is logged, and any backup can be restored in one click.

### How it fits together

The plugin installs into cPanel as root and appears under **Advanced** in every account. Account owners choose which jobs to monitor.

A cron line like this:

    */5 * * * * /usr/bin/php /home/demo/public_html/cron.php >/dev/null 2>&1

becomes this:

    */5 * * * * /home/demo/.zactonz/zcd/bin/zcd-run 4f2c9a10b7d3e185

The command itself is not rewritten into the crontab. It is stored verbatim in the account's own directory and executed from there, so nothing has to survive a second round of shell quoting, a `%` in a command cannot be turned into a newline by cron, and no text the plugin assembles is ever interpreted as shell. The schedule stays in the crontab, so changing it from cPanel's own **Cron Jobs** page keeps working.

The runner lives in the account's home directory rather than in the plugin directory. That is deliberate: under CloudLinux CageFS the plugin directory is not visible to the account's cron jobs at all, and it means removing the plugin cannot stop a job that was being monitored.

### Safety

Cron Doctor grants no privilege the account does not already have. Anything the runner executes, the account could already have put in its own crontab.

Every change to a crontab is written by installing a complete file, then reading it back and comparing it with what was intended. A mismatch restores the previous version automatically. A change is refused outright if the crontab moved after the page was loaded, so two browser tabs, or a concurrent edit in cPanel's own Cron Jobs page, cannot silently overwrite each other.

The cPanel API 2 `Cron` functions are not used for writes. `Cron::remove_line` takes a single `line` integer whose meaning is ambiguous between a file line number and a command number, and deleting the wrong cron job is not a recoverable mistake.

### Licensing

Cron Doctor is free and open source under the Apache License 2.0. You may use it on your own servers, modify it, and resell or white-label it under your own brand. It is distributed as-is, without warranty; review the code before running it in production.

### Contributing

The source is on [GitHub](https://github.com/zactonz/cron-doctor). Bug reports and pull requests are welcome. `bash tools/lint.sh` runs everything that has to pass: PHP syntax across supported versions, `shellcheck` plus `dash` and `bash` parsing of every shell script, the source policy check, and the full test suite.

## Requirements

| Component | Minimum |
|---|---|
| Operating system | Any that cPanel itself supports |
| cPanel & WHM | 102 or newer, Jupiter theme |
| PHP | 7.4 or newer for the interface, with `mbstring` and `json` |
| Shell | Any POSIX shell for the runner; `/bin/sh` is used by default |
| Access | Root, over SSH or WHM Terminal, to install |

### Operating system

There is no operating-system-specific code in the plugin, so it runs wherever cPanel does. cPanel currently
supports **AlmaLinux OS**, **CloudLinux**, **Rocky Linux** and **Ubuntu**; cPanel's own
[system requirements](https://docs.cpanel.net/installation-guide/system-requirements/) are the authority on
which versions of each are supported at any moment, and that list changes as distributions reach end of life.

Windows is not supported, because cPanel itself does not run on it.

Cron Doctor was verified on cPanel 11.138 (x86_64). What it actually depends on is a POSIX shell and PHP;
everything else is optional and described below.

### Running commands

To change a crontab, the cPanel interface needs PHP's `proc_open`, and a working `crontab` command must be reachable.

On a cPanel server `/usr/local/bin/crontab` is the one that works for an account; the raw `/usr/bin/crontab` exists but cannot read `/var/spool/cron/<user>` from inside the account's jail. Cron Doctor does not assume either: it tries each candidate and uses the first that actually returns the crontab.

Where commands cannot be run at all, the plugin falls back to **read-only mode**. Every check still runs and every finding still explains what to change, but nothing is written and the buttons that would change something are not shown. This is stated at the top of the dashboard rather than left to be discovered.

### Optional commands

`flock`, `timeout` and `setsid` are used when present and none of them are required.

- **`flock`** gives locking that the kernel releases when a process dies, so a stale lock is impossible. Without it, locking falls back to an atomic directory with a process id inside, and a lock left by a killed run is broken by the next one.
- **`timeout`** or **`setsid`** allow a job that reaches its time limit to be stopped together with any background work it started. Where neither exists, the runner stops the job process itself and records that background work may have survived, so the interface can say so honestly. Time limits are off by default, so this only matters if you turn one on.

On a stock AlmaLinux or CloudLinux cPanel server all three are present at `/usr/bin/`.

### Per-account storage

Everything belonging to an account lives in `~/.zactonz/zcd`, created with owner-only permissions. Captured output is kept there, never inside a document root and never served as a file. How much is kept is configurable per job, and it can be deleted from the interface at any time.

## Installation

Cron Doctor is installed once, as `root`, and is then available to every cPanel account on the server.

### Install

```bash
cd /root && curl -fsSL https://github.com/zactonz/cron-doctor/releases/latest/download/cron-doctor.tar.gz | tar -xz && bash cron-doctor/install.sh
```

That always fetches the current release, so the command does not change between versions.

If you would rather check the download before running it, every release ships a checksum beside it:

```bash
cd /root && curl -fsSLO https://github.com/zactonz/cron-doctor/releases/latest/download/cron-doctor.tar.gz && curl -fsSLO https://github.com/zactonz/cron-doctor/releases/latest/download/cron-doctor.tar.gz.sha256 && sha256sum -c cron-doctor.tar.gz.sha256 && tar -xzf cron-doctor.tar.gz && bash cron-doctor/install.sh
```

Past versions are listed under [Releases](https://github.com/zactonz/cron-doctor/releases).

The installer copies the plugin into cPanel's Jupiter theme directory, registers it with cPanel and restarts the interface. Log in to any cPanel account and look for **Cron Doctor** under **Advanced**. If the icon does not appear straight away, log out and back in so the theme cache refreshes.

To install from a clone instead:

```bash
git clone https://github.com/zactonz/cron-doctor.git && cd cron-doctor && bash install.sh
```

### Update

Run the same install command again; it always fetches the current release. It replaces the plugin files and removes any left by the previous version.

Monitored jobs, recorded runs and settings live in each account's home directory and are untouched by an update. The small runner kept in each home directory is refreshed automatically the next time that account opens Cron Doctor, so no crontab has to change.

### Uninstall

From the extracted package directory:

```bash
bash uninstall.sh
```

This removes the plugin and its cPanel registration.

**It does not change any crontab.** Jobs that were being monitored keep running, because the runner lives in each account's home directory rather than in the plugin directory. If you want the original crontab lines back, use **Stop monitoring** on each job before uninstalling.

### Feature Manager

The plugin registers with cPanel's Feature Manager, so resellers can switch it off for particular packages in the usual way.

## How to use

Open **Cron Doctor** under **Advanced** in cPanel. The dashboard lists every cron job on the account, whatever created it, along with anything Cron Doctor has found.

### Monitor a job

Press **Monitor** next to a job. Cron Doctor stores the original crontab line, moves the command into the account's own directory and replaces the command in the crontab with a call to its runner. The schedule is left exactly as it was.

From the next scheduled run onwards the job page shows when it ran, how long it took, the exit code and whatever it printed.

Nothing else about the job changes, with three exceptions:

1. **Overlapping runs are skipped.** Cron will happily start a second copy of a job while the first is still running. A monitored job takes a lock first, and a run that cannot take it is recorded as skipped rather than started. This can be set back to "let both run" per job.
2. **Cron email arrives only on failure.** A monitored job prints nothing when it succeeds and prints its captured output when it fails, so you get mail when something is wrong instead of after every run.
3. **A job stopped at a time limit exits 124**, following the convention of the `timeout` command. Otherwise the job's own exit code is passed through untouched.

A job whose command already ends in `>/dev/null 2>&1` keeps producing no output, so monitoring it changes nothing about what is mailed. It still gains an exit code, a duration and overdue detection.

### Stop monitoring

**Stop monitoring** on the job page puts the original command back and forgets the recorded runs. If the schedule was changed from cPanel's Cron Jobs page in the meantime, the current schedule is kept — only the command is restored.

### Recover discarded output

A job ending in `>/dev/null 2>&1` throws away its error messages along with everything else. Once a job is monitored, **Capture the output** removes that redirection so Cron Doctor can keep it.

This is only offered when the trailing redirection can be shown to be safe to remove: the command is tokenised with a quote-aware scanner, the file descriptor assignments are simulated in order, and only redirections to `/dev/null` at the very end of the command are considered. A redirection inside quotes, or a command with an unbalanced quote, is never touched. Removing it changes the stored command only — the crontab is not modified at all.

### Fix the PHP path

Where a job runs `php` or `/usr/bin/php`, Cron Doctor reads the PHP version cPanel has assigned to the site the script belongs to and offers **Use the right PHP**, which switches the job to that EasyApache binary.

This matters more than it sounds. On a normal cPanel server `/usr/bin/php` is not the CLI binary at all — it is `php-cgi`, which behaves differently and may be a different version from the one the website runs.

For a job that is already monitored the fix changes only the stored command and never touches the crontab.

### Set a time limit

Time limits are off by default, because adding one changes behaviour: a job that used to run for an hour would start being killed.

Set one on the job page and the runner stops the job when it is reached, sending SIGTERM and then SIGKILL five seconds later, and records the run with exit code 124. Where the server has `timeout` or `setsid`, the whole process group is stopped, so background work the job started goes with it.

### Turn on alerts

Put an address in **Settings › Alerts** and Cron Doctor adds one line to the crontab that checks every fifteen minutes and mails you when a monitored job fails or has not run when it should have. It also tells you when a job recovers.

Clearing the address removes that line again. A reminder interval controls how long to wait before repeating an alert for the same problem; the default is a day.

Failures also reach you through cron's own email whenever a job prints something. Only Cron Doctor can tell you that a job did not run at all.

### If something goes wrong

Cron Doctor saves a copy of the crontab before every change it makes and keeps the newest thirty, listed under **Settings › Crontab backups** with a one-click restore. **Settings › Recent activity** records every change it has made on the account.

## What it checks

Checks run every time the dashboard is opened. They are ordered by how much they matter, and a check that cannot finish is reported as one degraded finding rather than taking the page down.

### Needs attention

| Check | What it means |
|---|---|
| A line in the crontab is not a cron job | A line is neither a comment, a setting nor a job. Cron usually refuses to load the whole crontab, which silently stops every job in it. |
| The crontab has Windows line endings | Cron passes the carriage return to the shell, so the command fails with a confusing "No such file or directory". |
| Points at a file that is not there | A path in the command does not exist in the account. Usually a script that was moved, renamed or deleted. |
| The last run failed | The last run, or the last several, exited non-zero. The captured output is on the job page. |
| It has not run when it should have | The job was due and there is no record of it running. |
| Cron email is switched off | `MAILTO` is empty, so cron will never send mail about anything. |
| Other users can read the Cron Doctor folder | The folder holding captured output is readable by other accounts on the server. |

### Worth fixing

| Check | What it means |
|---|---|
| Runs PHP from a generic path | The job runs `php` or `/usr/bin/php` rather than the site's EasyApache binary. On cPanel `/usr/bin/php` is usually `php-cgi`. **One-click fix.** |
| WordPress cron is triggered more than once | More than one line drives WordPress cron for the same installation, so scheduled tasks run more often than intended. |
| WordPress is still running its own cron as well | A real cron job drives WordPress cron but `DISABLE_WP_CRON` is not set, so tasks run twice and busy pages get slower. Cron Doctor only reads `wp-config.php`; it does not change it. |
| Throws its output away | Everything the job prints, errors included, goes to `/dev/null`. **One-click fix** once the job is monitored. |
| Runs are overlapping | Runs are being skipped because the previous one is still going. |
| The last run was stopped at its time limit | The job reached the limit set for it. |
| A stopped job may have left work running | The server has neither `timeout` nor `setsid`, so background work may survive a time limit. |
| The command has an unclosed quote | The shell may interpret it very differently from what was intended. |
| The same job appears twice | An identical schedule and command on two lines, so the command runs twice. |
| The schedule cannot be read | Syntax outside what Cron Doctor parses. The job is still listed and still recorded; only the overdue judgement is unavailable. |
| Cron Doctor is in read-only mode | Commands cannot be run on this account, so nothing will be written. |
| The runner in your home directory is out of date | The copy there does not match the installed plugin. One click repairs it. |

### Suggestions

| Check | What it means |
|---|---|
| Uses a different PHP version from the site | The job runs one EasyApache version while the site it belongs to is set to another. **One-click fix.** |
| WordPress cron runs very often | Most sites are fine with every five or fifteen minutes. |
| Output is still being discarded | The job is recorded, but the command still ends with a redirection to `/dev/null`. **One-click fix.** |
| It nearly takes as long as the gap between runs | Runs will start overlapping if the job gets any slower. |
| The command contains a credential | A password or token on the command line is visible to anyone who can list processes, and sits in the crontab in plain text. |
| Runs only when the server restarts | `@reboot` has no next scheduled time, so it can never be judged overdue. |
| Jobs that are not monitored yet | Nothing is being recorded about them. |
| Time limits cannot be enforced cleanly here | Neither `timeout` nor `setsid` is available to the account. |

### What it will not do on its own

Cron Doctor never edits a crontab without being asked, never changes `wp-config.php`, and never deletes a cron job. The one-click fixes rewrite a command; each says exactly what it will change before you press it, and the crontab is backed up first.

Credentials are masked in the command shown in the interface. That is a convenience for screenshots, not a security control — the real value is still in the crontab and in the process list while the job runs, and the interface says so.

## FAQ

### Does it change my cron jobs?

Only the ones you ask it to, and only the command. The schedule stays in the crontab where cPanel's own Cron Jobs page can still edit it. The original line is stored so it can be put back exactly.

### Can I undo it?

Yes. **Stop monitoring** on the job page restores the original command. Cron Doctor also saves a copy of the whole crontab before every change it makes and keeps the newest thirty, each restorable in one click from **Settings**.

### What happens to my jobs if I uninstall the plugin?

Nothing. They keep running. The runner lives in each account's home directory, not in the plugin directory, so removing the plugin cannot stop a monitored job. If you would rather have the plain crontab lines back, use **Stop monitoring** on each job before you uninstall.

### Will it slow my jobs down?

No measurably. On a cPanel server the runner adds about four milliseconds per run. A job with no time limit is executed directly, with nothing polling it.

### Will I stop getting cron email?

You will stop getting it for successful runs, which is the point. A monitored job stays quiet when it succeeds and prints its captured output when it fails, so cron mails you only when something is wrong. This is per-job and can be set back to every run, or to never.

If your crontab sets `MAILTO` to an empty value, cron sends nothing at all, and Cron Doctor reports that as a problem.

### Who can read the captured output?

Only the account owner and root. It is written with owner-only permissions into a folder in the account's home directory, never inside a document root, and it is never served as a file — the interface reads it and escapes it. It can be deleted per job or for the whole account at any time, and how much is kept is configurable.

### Does it work under CloudLinux and CageFS?

Yes, and the design assumes it. The runner is deployed into the account's home directory precisely because CageFS hides `/usr/local/cpanel` from the account's cron jobs.

One consequence worth knowing: the cPanel interface runs outside the jail while cron runs inside it, so the two can see different filesystems. The "file is not there" check therefore only considers paths inside the account's home directory, where both see the same thing.

### What if my account cannot run commands?

Cron Doctor falls back to read-only mode: it reads the crontab through the cPanel API, runs every check and explains every finding, but writes nothing. The dashboard says so at the top.

### Does it need a database or any dependencies?

No. No database, no Composer, no third-party libraries. State is JSON files in the account's home directory.

### Can it monitor a job that contains a `%`?

Yes. In a crontab the first unescaped `%` ends the command and everything after it becomes the job's standard input, with each further `%` turned into a newline. Cron Doctor implements that rule exactly, stores the command and its input separately, and feeds the input to the job from a file. Because the wrapped crontab line contains no `%` at all, a command such as `date +\%Y` cannot be damaged by being monitored.

### What about `@reboot` jobs?

They are listed and can be monitored, and their runs are recorded. They are never reported as overdue, because `@reboot` has no next scheduled time to compare against.

### Which PHP does the alert check use?

The newest EasyApache version installed, falling back to `/usr/local/bin/php`. It needs PHP 7.4 or newer and says so on standard error if it cannot find one, at most once every six hours so a broken server does not fill your inbox.

### Is it safe to run on a shared server?

It grants no privilege an account does not already have — anything the runner executes, that account could already have put in its own crontab. The runner refuses to run at all if its directory or command file is owned by another user or writable by anyone but the owner. The full reasoning is in the repository under `docs/security.md`.

### Where do I report a problem?

Open an issue on [GitHub](https://github.com/zactonz/cron-doctor/issues). For something security-sensitive, email **security@zactonz.com** instead of filing a public issue.
