Disable wp-cron and use system cron
WP-Cron only fires when someone loads a page, so it stalls on quiet sites and piles up on busy ones. Move it to system cron with WP-CLI, and verify it ran.
What wp-cron is, and why system cron replaces it
WP-Cron is the task scheduler built into WordPress, and it only runs when someone requests a page. Nothing inside WordPress wakes up on its own. On every request that is not served from a cache, WordPress reads a list of scheduled jobs, and if one is due it fires a second HTTP request back to itself at /wp-cron.php to do the work. Moving that job to system cron gives you one predictable run on a fixed schedule, whether the site had a thousand visitors that minute or none.
Two lines do the actual work: a constant in wp-config.php, and a crontab entry. Everything else in this guide is the part those two lines do not tell you. Which user the job must run as, how to prove the scheduled events really fired, and the three ways the setup fails without printing anything on the site.
The examples use /srv/www/example.com as the WordPress directory and www-data as the web server user. Substitute your own paths and user everywhere.
What visitor triggered cron costs on a busy site
Every uncached request pays for the check. WordPress loads the cron option, compares timestamps, and when something is due it calls spawn_cron(), which sends a non-blocking loopback request to /wp-cron.php. The visitor does not wait for the result. A PHP worker does. On a small VPS running PHP-FPM with pm.max_children = 5, one slow scheduled job holds a fifth of your PHP capacity for as long as it takes, and it is most likely to be triggered during your busiest minute, because that is when the most page loads happen.
WordPress does limit duplicates. It takes a lock whose lifetime is WP_CRON_LOCK_TIMEOUT, 60 seconds by default, so simultaneous visitors do not each start a run. The lock caps duplication. It does not move the work off the request path.
Count how often it fires on your own server before deciding it matters. Each loopback appears in the web server access log:
sudo grep -c 'wp-cron.php' /var/log/nginx/access.logApache writes to /var/log/apache2/access.log instead. A count in the thousands per day is a real cost, and it is the kind of number you should measure on your own box rather than read in an article, the same way you would benchmark a VPS before and after any other change.
Caching changes the picture. If a page cache serves most requests as static HTML, PHP never runs for those requests, so the cron check never happens. A heavily cached busy site starts to behave like the quiet site below.
What visitor triggered cron breaks on a quiet site
No visitors means no cron. A site that gets a handful of visits per day runs its scheduled jobs a handful of times per day, at whatever random moments those visits arrive.
The symptoms are all the same shape. A post scheduled for 09:00 stays in the posts list marked Missed schedule until somebody loads a page. Backup plugins skip the night. Update checks lag, so the dashboard shows nothing to update while a security release is already out. Order emails, renewal notices and expiry warnings go out late.
None of this logs an error. The job was never late in WordPress's view, because it was never started.
Step 1: turn off the visitor trigger in wp-config.php
Open /srv/www/example.com/wp-config.php and add the constant:
define( 'DISABLE_WP_CRON', true );Put it above the line that reads /* That's all, stop editing! Happy publishing. */, because the line just below that comment requires wp-settings.php, and wp-settings.php is where WordPress hooks the cron check onto init. A constant defined after that require is set too late to change anything, and the file looks correct while the trigger keeps running.
Confirm the line is where you think it is:
grep -n "DISABLE_WP_CRON\|stop editing" /srv/www/example.com/wp-config.phpThe constant does not stop events being scheduled. Plugins keep adding jobs to the queue, exactly as before. It only stops page loads from running that queue, which means the queue now runs never until you finish step 3.
It also does not block direct requests to /wp-cron.php. Anyone can still request that URL, and that is usually harmless because the file only runs what is due. Blocking it in your web server config is optional. If you do block it, the curl fallback near the end of this guide stops working too.
Step 2: install WP-CLI
WP-CLI is the official command line tool for WordPress. It needs the PHP command line binary, which is a separate package from the web server's PHP module.
php -v
sudo apt install -y php-cliInstall WP-CLI from the phar build, which is what the official install guide recommends:
cd /tmp
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
php wp-cli.phar --info
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp
wp --infophp wp-cli.phar --info prints the PHP binary path, the PHP version and the WP-CLI version. If it prints all three, the phar works. As of August 2026 the install guide gives PHP 7.2.24 as the minimum, and Ubuntu 24.04 ships PHP 8.3, so a current server is well clear of that line. Update later with sudo wp cli update.
Run WP-CLI as the site's user, never as root:
sudo -u www-data /usr/local/bin/wp --path=/srv/www/example.com core versionAs root, WP-CLI refuses to start:
Error: YIKES! It looks like you're running this as root.It suggests --allow-root. Do not use that here. The reason is in the first failure mode below.
Also note that sudo -u www-data -i does not work, because that account's login shell is /usr/sbin/nologin and you get This account is currently not available. Passing the command straight to sudo -u skips the login shell, so it runs fine.
Now confirm WordPress itself sees the constant from step 1:
sudo -u www-data /usr/local/bin/wp --path=/srv/www/example.com eval 'var_dump( DISABLE_WP_CRON );'That prints bool(true). A fatal error about an undefined constant means the define() line is not being reached, which usually means it landed below the require.
Step 3: add the cron entry as the right user
The correct user is the one that owns the files PHP writes. Check both ends:
stat -c '%U %G' /srv/www/example.com/wp-content/uploads
grep -E '^(user|group) = ' /etc/php/8.3/fpm/pool.d/www.confOn a default Ubuntu install both answer www-data. If you gave the site its own PHP-FPM pool with its own user, which is where a per-site setup on a LAMP stack on Ubuntu 24.04 normally ends up, use that user for everything below.
Make a log directory that user can write:
sudo install -d -o www-data -g www-data -m 750 /var/log/wp-cronEdit that user's crontab:
sudo crontab -u www-data -eAdd one line:
*/5 * * * * /usr/bin/flock -n /run/lock/wp-cron-example.lock /usr/local/bin/wp --path=/srv/www/example.com cron event run --due-now >> /var/log/wp-cron/example.log 2>&1Piece by piece. */5 runs it every five minutes. flock -n takes a lock file and gives up immediately if a previous run still holds it. /usr/local/bin/wp is the absolute path, which cron needs. --path lets the command run from any working directory. --due-now runs only the events whose time has arrived, instead of every event in the queue. The redirect sends normal output and errors to one file you can read.
That redirect is not optional in practice. Cron mails a job's output to its user, most VPS images have no mail transfer agent installed, and cron then logs (CRON) info (No MTA installed, discarding output) and throws the output away. A file keeps the evidence.
Check the file saved:
sudo crontab -u www-data -lFor several sites, use one line each with staggered minutes so they do not all start together:
*/5 * * * * /usr/bin/flock -n /run/lock/wp-cron-one.lock /usr/local/bin/wp --path=/srv/www/one.example.com cron event run --due-now >> /var/log/wp-cron/one.log 2>&1
2-59/5 * * * * /usr/bin/flock -n /run/lock/wp-cron-two.lock /usr/local/bin/wp --path=/srv/www/two.example.com cron event run --due-now >> /var/log/wp-cron/two.log 2>&1The log grows forever unless you rotate it. Write /etc/logrotate.d/wp-cron:
/var/log/wp-cron/*.log {
weekly
rotate 4
missingok
notifempty
compress
create 640 www-data www-data
}Check it parses without touching anything: sudo logrotate --debug /etc/logrotate.d/wp-cron.
Step 4: confirm the scheduled events really fired
A crontab line that saved successfully proves nothing. Work up from the cheapest check to the one that actually settles it.
First, did cron start the command? Cron logs to the journal under its own unit:
journalctl -u cron.service --since "15 min ago" | grep wpA healthy entry looks like this, with the timestamp and host name trimmed off the front:
CRON[24913]: (www-data) CMD (/usr/bin/flock -n /run/lock/wp-cron-example.lock /usr/local/bin/wp --path=/srv/www/example.com cron event run --due-now >> /var/log/wp-cron/example.log 2>&1)That line means cron started your command as www-data. It says nothing about whether the command worked.
Second, did WordPress execute anything? Read the log file:
sudo tail -n 20 /var/log/wp-cron/example.logWP-CLI prints one line per event, then a total:
Executed the cron event 'wp_version_check' in 0.418s.
Success: Executed a total of 2 cron events.Errors land in the same file, which is the whole point of 2>&1. Most runs will have nothing due and will write very little, so read the file after a run you know had work waiting.
Third, prove it end to end. Schedule a marker event and watch it disappear:
sudo -u www-data /usr/local/bin/wp --path=/srv/www/example.com cron event schedule wp_cli_cron_check now
sudo -u www-data /usr/local/bin/wp --path=/srv/www/example.com cron event list --fields=hook,next_run_relative --format=csv | grep wp_cli_cron_checkWait for one interval, then run the list command again. The hook is gone, because a one off event is removed from the queue when it runs. No plugin registers a callback on that hook name, so running it does nothing else to the site. If the hook is still listed after two intervals, the queue is not being run, and the first two checks tell you whether the problem is cron or WP-CLI.
Do not use wp cron test for this. That command checks whether visitor triggered spawning works, and it errors when DISABLE_WP_CRON is true. On a correctly configured server the error is the expected output, not a fault.
The systemd timer alternative
If the rest of the box's scheduled work already runs as systemd services and timers, put WordPress there too. Every run then shows up in systemctl list-timers, and the output goes to the journal instead of a file you have to rotate.
Write /etc/systemd/system/wp-cron-example.service:
[Unit]
Description=Run due WordPress cron events for example.com
[Service]
Type=oneshot
User=www-data
Group=www-data
ExecStart=/usr/local/bin/wp --path=/srv/www/example.com cron event run --due-nowThen /etc/systemd/system/wp-cron-example.timer:
[Unit]
Description=Run WordPress cron for example.com every 5 minutes
[Timer]
OnCalendar=*:0/5
AccuracySec=30s
Persistent=true
[Install]
WantedBy=timers.targetsudo systemctl daemon-reload
sudo systemctl enable --now wp-cron-example.timer
systemctl list-timers wp-cron-example.timer
journalctl -u wp-cron-example.service -n 20Systemd will not run two copies of the same service at once, so this version does not need flock. Persistent=true makes it catch up on a run missed while the machine was off, which a crontab entry cannot do.
Pick the crontab or the timer. Running both against the same site means the queue is being drained twice, and duplicate runs of an email or order job are visible to your customers.
Why the cron job must not run as root
This is the first of the three ways the setup fails. Put the job in root's crontab and WP-CLI stops before it does anything:
Error: YIKES! It looks like you're running this as root.The queue never runs, and if you did not redirect output you never see the message. The dangerous fix is adding --allow-root, because then every file a plugin writes during that run belongs to root. The next web request runs as www-data, cannot write into those directories, and the site starts reporting things like:
Unable to create directory wp-content/uploads/2026/08. Is its parent directory writable by the server?Repair the ownership, then move the job:
sudo chown -R www-data:www-data /srv/www/example.com/wp-contentRoot's crontab and www-data's crontab are separate files, so deleting the line from one does not touch the other. Check both:
sudo crontab -u root -l
sudo crontab -u www-data -lWhy cron reports wp: not found
This is the second failure. Cron gives user jobs a very short PATH, /usr/bin:/bin. WP-CLI installs into /usr/local/bin, which is not on that list. The job starts, fails in a fraction of a second, and the log holds one line:
/bin/sh: 1: wp: not foundSee cron's environment yourself instead of guessing. Add a temporary line:
*/5 * * * * env > /tmp/cron-env.txt 2>&1Read /tmp/cron-env.txt after one interval, then delete the line. The PATH= value in that file is exactly what your job gets.
There are two fixes. Use the absolute path /usr/local/bin/wp, as in step 3. Or set the PATH once at the top of the crontab, above every job line:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binThe same trap sits one level down. The wp phar begins with #!/usr/bin/env php, so the shell must be able to find php as well. If PHP lives outside /usr/bin, which happens with custom builds and control panel builds, you get:
/usr/bin/env: 'php': No such file or directoryCall the interpreter explicitly in that case, for example /usr/local/bin/php /usr/local/bin/wp --path=/srv/www/example.com cron event run --due-now.
Why a one minute interval recreates the original problem
This is the third failure. * * * * * feels safer than five minutes, and on a busy site it puts you back where you started. If one run takes longer than the interval, the next run starts while the first is still going. Ten minutes later there are ten PHP processes, each holding its own memory and its own database connection.
Look for a pileup directly:
ps -eo etimes,user,args | grep '[c]ron event run'etimes is the process age in seconds. One line is healthy. Several lines with ages far above your interval means runs are stacking, and on a small VPS that ends as a MySQL Too many connections error, or as the kernel killing PHP to reclaim memory, which you can confirm with sudo dmesg -T | grep -i 'killed process'.
WP-CLI runs the event callbacks directly rather than requesting wp-cron.php, so the 60 second lock WordPress uses against duplicate spawns does not apply here. flock -n in the step 3 entry is what prevents overlap now. A skipped run exits immediately and silently, by design.
Choose the interval from the shortest schedule you actually depend on, and measure a run first:
time sudo -u www-data /usr/local/bin/wp --path=/srv/www/example.com cron event run --due-nowFive minutes is a sensible default: a post scheduled for 09:00 publishes by 09:05. Fifteen minutes is fine for a site with nothing time critical on it. One minute belongs to stores and queue driven plugins that genuinely need it, and only once you know a run finishes in a few seconds.
If you cannot install WP-CLI
Some hosts block shell tools. A plain HTTP request to wp-cron.php runs the same queue, just through the whole web stack:
*/5 * * * * /usr/bin/curl -sS --max-time 120 "https://example.com/wp-cron.php?doing_wp_cron" > /dev/nullWhat you give up, plainly:
- The run is capped by the web server and PHP-FPM request timeouts, so a long job can be cut off mid way.
- The certificate must be valid or
curlstops withSSL certificate problem, so keep renewals working with Certbot on nginx. - Page caching must not cache
wp-cron.php, or cron requests get a cached response and nothing runs. - You get no per event output, so the only evidence a job ran is the effect it had.
-sS keeps curl quiet on success while still printing errors, which is what you want in a cron job.
What else belongs on the server's schedule
Once system cron owns the WordPress queue, keep the rest of the box's routine work in the same place, where you can see it. Operating system security patches belong to unattended upgrades rather than a cron line you maintain by hand. WordPress plugin and theme updates are a different decision: wp plugin update --all in a crontab will happily break a live site at 3am with nobody watching, so run that deliberately, or behind a staging step and a backup.
FAQ
Does disabling WP-Cron stop scheduled posts from publishing?
No, as long as something else runs the queue. DISABLE_WP_CRON only stops page loads from triggering the queue. Events are still scheduled exactly as before. A post set for 09:00 publishes on the first cron run after 09:00, so a five minute interval publishes it by 09:05. If you set the constant and never add the cron entry, the post sits in the list marked Missed schedule until something runs the queue.
Which user should run the WordPress cron job?
The user that owns the files PHP writes, which is www-data on a default Ubuntu install. Check with stat -c '%U %G' /srv/www/example.com/wp-content/uploads and compare it against the user = line in your PHP-FPM pool config. Running the job as root makes WP-CLI stop with a YIKES error, and forcing it through with --allow-root leaves root owned files inside wp-content that the web server cannot write afterwards.
How often should system cron run WordPress cron?
Every five minutes suits most sites. Match the interval to the shortest schedule you truly rely on, and keep it comfortably above the time a single run takes, which you can measure by putting time in front of the WP-CLI command. One minute intervals stack runs on top of each other on a busy site unless flock guards them.
Why does wp cron test fail after I disable WP-Cron?
Because that command tests visitor triggered spawning, and it reports an error when DISABLE_WP_CRON is set to true. That is the correct result on a server configured this way. Check the system cron path instead: read /var/log/wp-cron/example.log, or schedule a marker event with wp cron event schedule and confirm it has vanished from wp cron event list after the next run.
Do I need WP-CLI, or is curl to wp-cron.php enough?
Curl works, and it is the right answer when you cannot install WP-CLI. It is slower, because it loads WordPress through the web server, and it is limited by the request timeout. WP-CLI runs the events in a command line PHP process with no web timeout, and prints one line per event with its duration, so the log tells you exactly what ran and how long it took.