Root Cause Diagnosis
WP-Cron is not a scheduler. On every front-end and admin request, wp_cron() checks for due events and, if any exist, spawn_cron() fires a non-blocking loopback POST to /wp-cron.php, which boots the full WordPress stack in a new PHP worker. The doing_cron lock is a transient with a 60-second timeout (WP_CRON_LOCK_TIMEOUT) that is not checked atomically, so simultaneous requests can each spawn their own wp-cron.php process. A backlog of overdue events (Action Scheduler, backups, imports) or a slow hook keeps those workers and DB connections busy, so CPU scales with request rate rather than with the schedule. If the loopback fails (DNS, Basic Auth, WAF, self-signed TLS), the event is never cleared and every request retries the spawn.
Quick Fix
Disable request-triggered cron and run due events from a transient systemd timer (lost on reboot; the permanent version is in the steps below):
WP=/var/www/example.com/public_html
sudo -u www-data wp config set DISABLE_WP_CRON true --raw --path="$WP"
sudo -u www-data wp cron event run --due-now --path="$WP"
sudo systemd-run --unit=wp-cron-quick --on-calendar='*:0/5' \
--uid=www-data --working-directory="$WP" \
/usr/local/bin/wp cron event run --due-now --path="$WP"
Replace www-data with the PHP-FPM pool user (nginx, apache, www, or a per-site user).
Step-by-Step Resolution
1. Confirm the diagnosis
# Loopback volume
grep -c '/wp-cron.php' /var/log/nginx/access.log # Apache: /var/log/apache2/access.log
# Overdue events and schedule intervals
sudo -u www-data wp cron event list --fields=hook,next_run_gmt,next_run_relative --format=table --path="$WP"
sudo -u www-data wp cron schedule list --path="$WP"
# Loopback health
sudo -u www-data wp cron test --path="$WP"
A high wp-cron.php hit count, a long list of past-due events, or a failing wp cron test confirms it. Note the smallest interval in wp cron schedule list, because the timer in step 4 must fire at least that often.
2. Disable request-triggered cron
/var/www/example.com/public_html/wp-config.php
define( 'DISABLE_WP_CRON', true );
Place it above require_once ABSPATH . 'wp-settings.php';. Constants defined after that line are ignored. wp config set DISABLE_WP_CRON true --raw places it correctly.
3. Block direct HTTP access to wp-cron.php
DISABLE_WP_CRON only stops the page-load spawn. A direct request to /wp-cron.php still executes it, so external pingers and bots can still trigger it.
/etc/nginx/conf.d/example.com.conf (place before the generic location ~ \.php$ block):
location = /wp-cron.php {
allow 127.0.0.1;
allow ::1;
deny all;
}
.htaccess or the vhost (Apache 2.4):
<Files "wp-cron.php">
Require ip 127.0.0.1 ::1
</Files>
sudo nginx -t && sudo systemctl reload nginx # Apache: apachectl configtest && systemctl reload apache2
4. Create the service unit
/etc/systemd/system/wp-cron.service
[Unit]
Description=WordPress due-now cron (example.com)
Wants=network-online.target
After=network-online.target mariadb.service
[Service]
Type=oneshot
User=www-data
Group=www-data
WorkingDirectory=/var/www/example.com/public_html
ExecStart=/usr/bin/php /usr/local/bin/wp cron event run --due-now --path=/var/www/example.com/public_html
TimeoutStartSec=300
Nice=10
IOSchedulingClass=idle
CPUQuota=50%
MemoryMax=512M
- Call
phpexplicitly (/usr/bin/php8.3, or the panel’s PHP path) so the version matches the site’s FPM version. Type=oneshotmeans systemd will not start a second instance while one is running, so runs never overlap.TimeoutStartSeckills a hung hook instead of blocking all later runs.- If
mariadb.servicedoes not exist (mysql.service,mysqld.service, remote DB), remove it fromAfter=.
5. Create the timer unit
/etc/systemd/system/wp-cron.timer
[Unit]
Description=Run WordPress cron every 5 minutes
[Timer]
OnCalendar=*:0/5
AccuracySec=1s
Persistent=true
[Install]
WantedBy=timers.target
Use OnCalendar=minutely if wp cron schedule list shows a 60-second interval (WooCommerce Action Scheduler registers action_scheduler_run_queue every minute).
6. Enable, run once, verify
sudo systemctl stop wp-cron-quick.timer wp-cron-quick.service 2>/dev/null # remove the transient units from Quick Fix
sudo systemctl daemon-reload
sudo systemctl enable --now wp-cron.timer
sudo systemctl start wp-cron.service
systemctl list-timers wp-cron.timer
journalctl -u wp-cron.service -n 50 --no-pager
Enable the .timer, never the .service. The service is only the job the timer starts.
7. Multiple sites: template units
/etc/systemd/system/wp-cron@.service
[Unit]
Description=WordPress due-now cron (%i)
After=network-online.target
[Service]
Type=oneshot
User=www-data
Group=www-data
WorkingDirectory=/var/www/%i/public_html
ExecStart=/usr/bin/php /usr/local/bin/wp cron event run --due-now --path=/var/www/%i/public_html
TimeoutStartSec=300
Nice=10
IOSchedulingClass=idle
/etc/systemd/system/wp-cron@.timer
[Unit]
Description=WordPress cron timer (%i)
[Timer]
OnCalendar=*:0/5
RandomizedDelaySec=60
AccuracySec=1s
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now wp-cron@example.com.timer wp-cron@example.org.timer
RandomizedDelaySec staggers sites so they do not all boot PHP at the same second. If per-site pool users differ, use a drop-in per instance:
sudo systemctl edit wp-cron@example.org.service
[Service]
User=exampleorg
Group=exampleorg
8. Multisite networks
wp cron event run --due-now handles one site per invocation, so iterate over every site ($$ escapes $ in unit files):
ExecStart=/bin/sh -c 'for u in $$(/usr/local/bin/wp site list --field=url --path=/var/www/example.com/public_html); do /usr/local/bin/wp cron event run --due-now --url="$$u" --path=/var/www/example.com/public_html; done'
9. WooCommerce / Action Scheduler
Action Scheduler runs its queue through WP-Cron plus its own async loopback runner. Run the queue directly and disable the async loopback:
sudo -u www-data wp action-scheduler run --path="$WP"
// mu-plugin: wp-content/mu-plugins/disable-as-async.php
add_filter( 'action_scheduler_allow_async_request_runner', '__return_false' );
Add a second ExecStart= line to the service, or a separate unit, for wp action-scheduler run. Type=oneshot accepts multiple ExecStart= lines, which run in order.
10. Verify CPU relief
grep -c '/wp-cron.php' /var/log/nginx/access.log # counts should stop growing, except 403s from outside
systemctl show wp-cron.service -p ExecMainStatus,ActiveEnterTimestamp,ActiveExitTimestamp
journalctl -u wp-cron.service --since "1 hour ago" | grep -ci 'error'
Why This Fails for Some Users
- Wrong user: Running as root creates root-owned cache and upload files, and WP-CLI refuses to run without
--allow-root. Always setUser=to the PHP-FPM pool user for that site. On panels with per-site users, the pool user is notwww-data. - CLI PHP is not FPM PHP: The CLI binary has its own /etc/php/8.x/cli/php.ini (RHEL family: /etc/php.ini or /opt/remi/php83/php.ini), with a different
memory_limit,disable_functions,open_basedir, and default socket. Verify withphp --iniandphp -r 'echo PHP_VERSION;'. A version mismatch causes fatal errors from plugins that require a newer PHP. - “Error establishing a database connection” from CLI only: With
DB_HOSTset tolocalhost, PHP connects through the Unix socket atmysqli.default_socket, and the CLI ini can point to a different path than FPM. SetDB_HOSTto127.0.0.1or fixmysqli.default_socketin the CLI ini. - APCu and opcache are not shared between CLI and FPM: An APCu-backed object cache populated by the web workers is invisible to the CLI process. Cron jobs that rely on cached state can behave differently. Use Redis or Memcached for a shared object cache.
- Old cron/pinger sources still hitting wp-cron.php: Check
crontab -l -u www-data,/etc/cron.d/, panel cron entries (curl .../wp-cron.php), external services such as cron-job.org, and plugins that defineALTERNATE_WP_CRON. After step 3, these produce 403s instead of PHP execution, but they should still be removed. - Timer never fires:
systemctl list-timers --allshowsn/awhen the.timerwas not enabled, has no[Install]section, orOnCalendarfailed to parse. Validate withsystemd-analyze calendar '*:0/5'. - Old systemd (CentOS/RHEL 7, systemd 219):
MemoryMax=is not supported; useMemoryLimit=.CPUQuota=requires the cpu cgroup controller to be enabled. - Hardening directives break the job:
ProtectHome=trueorProtectSystem=strictblock sites under/home/<user>/(common on CyberPanel and cPanel layouts). AddReadWritePaths=for the site path, or drop those directives. - Initial backlog exceeds
TimeoutStartSec: After a long period of broken cron, the first--due-nowrun can process thousands of events. Run it once manually undersudo -u www-dataand let it finish, or usesystemd-run --unit=wp-cron-backlog --uid=www-data ...with a larger timeout. One event with an unbounded remote HTTP call can otherwise consume the whole window. - Timer interval longer than the event interval: A 5-minute timer cannot honor a 1-minute schedule. Events run late, and Action Scheduler queues grow. Match
OnCalendarto the smallest interval reported bywp cron schedule list. - Containers: Docker has no systemd inside the container. Run the timer on the host and execute
docker exec -u www-data <container> wp cron event run --due-now, or use a dedicated sidecar container with the same code volume and a loop or supercronic. - Shared/managed hosting without root: systemd units are unavailable. Use the panel’s cron scheduler with
wp cron event run --due-now, orcurl -s https://example.com/wp-cron.phponly if the host blocks WP-CLI. The latter keeps the HTTP path and its overhead.