WordPress wp_cron.php causing high CPU usage: Proper CLI systemd timer setup

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

See also  Fixing WooCommerce Cart Session Dropping on Checkout Submission with Redis Cache

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 php explicitly (/usr/bin/php8.3, or the panel’s PHP path) so the version matches the site’s FPM version.
  • Type=oneshot means systemd will not start a second instance while one is running, so runs never overlap.
  • TimeoutStartSec kills a hung hook instead of blocking all later runs.
  • If mariadb.service does not exist (mysql.service, mysqld.service, remote DB), remove it from After=.

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

See also  WooCommerce Webhook Disabled Automatically Due to Delivery Failure: How to Reset

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 set User= to the PHP-FPM pool user for that site. On panels with per-site users, the pool user is not www-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 with php --ini and php -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_HOST set to localhost, PHP connects through the Unix socket at mysqli.default_socket, and the CLI ini can point to a different path than FPM. Set DB_HOST to 127.0.0.1 or fix mysqli.default_socket in 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 define ALTERNATE_WP_CRON. After step 3, these produce 403s instead of PHP execution, but they should still be removed.
  • Timer never fires: systemctl list-timers --all shows n/a when the .timer was not enabled, has no [Install] section, or OnCalendar failed to parse. Validate with systemd-analyze calendar '*:0/5'.
  • Old systemd (CentOS/RHEL 7, systemd 219): MemoryMax= is not supported; use MemoryLimit=. CPUQuota= requires the cpu cgroup controller to be enabled.
  • Hardening directives break the job: ProtectHome=true or ProtectSystem=strict block sites under /home/<user>/ (common on CyberPanel and cPanel layouts). Add ReadWritePaths= for the site path, or drop those directives.
  • Initial backlog exceeds TimeoutStartSec: After a long period of broken cron, the first --due-now run can process thousands of events. Run it once manually under sudo -u www-data and let it finish, or use systemd-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 OnCalendar to the smallest interval reported by wp 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, or curl -s https://example.com/wp-cron.php only if the host blocks WP-CLI. The latter keeps the HTTP path and its overhead.
See also  Rank Math SEO Schema Warning: Missing Field "hasCourseInstance" on Custom Post Type

Leave a Comment