WordPress runs its scheduled work through wp-cron.php and, by default, fires that script inline on visitor requests. Plugin maintenance then rides the request path: a page load can trigger whatever job is due, and under low traffic the jobs run late, whenever the next visitor happens to arrive. I run four WordPress sites on one self-managed virtual private server (VPS), carolegirard.fr, clementbodot.fr, pro.jngirard.com, and foozilla.net, and I moved all of them onto a system schedule. Moving the trigger is easy to describe and held two traps: a naive every-15-minute plan fires all four sites at the same minute, and a WordPress process running in the wrong user context creates wp-config.php or .htaccess owned by root.
The four sites reached that VPS in a blue-green migration off managed hosting, and they share the box with per-site isolation. Scheduling is the operational half of self-managed WordPress: owning the box means owning the clock.
TL;DR
I set DISABLE_WP_CRON = true in every site’s wp-config.php and moved each site’s wp-cron.php onto a staggered entry in a dedicated file under /etc/cron.d/ that runs as www-data, never as root. The four sites no longer fire together, and the jobs fire on time rather than on traffic. Request-path latency and firing unpredictability are gone; a plugin that depended on the HTTP loop broke, and its own fix is still open.
Moving cron out of the visitor path
The decision was to take WordPress off the request path entirely. DISABLE_WP_CRON = true in each site’s wp-config.php removes the inline trigger, and that one cron file replaces it. Each entry runs a site’s wp-cron.php through PHP‘s command line, /usr/bin/php /var/www/SITE_SLUG/htdocs/wp-cron.php, under an explicit www-data user field, with the four sites offset so they never overlap.
Two other answers were live, and both were rejected on a stated reason.
- Leave the inline default alone. It keeps the latency on the request path and the unpredictability under low traffic, which is the problem.
- Run the same command from the root crontab. I tried this one first, and a root-context WordPress process reverts file ownership; it caused INC-044, and it was replaced by the
www-dataentry above.
The guardrail that came out of it: these entries belong in a dedicated file under /etc/cron.d/ with an explicit user field, and never in the root crontab.
The stagger, site by site
Every site runs the same command, offset a few minutes apart. The wp-config.php change is one line, define('DISABLE_WP_CRON', true);. The schedule is that one cron file, one entry per site, each carrying the user field. The command is identical each time, with the site’s own directory in place of SITE_SLUG; one site’s line reads 12,27,42,57 * * * * www-data /usr/bin/php /var/www/<vhost>/htdocs/wp-cron.php.
| Site | Minutes past the hour |
|---|---|
| carolegirard.fr | 00, 15, 30, 45 |
| clementbodot.fr | 05, 20, 35, 50 |
| pro.jngirard.com | 10, 25, 40, 55 |
| foozilla.net | 12, 27, 42, 57 |
The offsets are the record’s own; they are a schedule, not a measured gain. The same calendar-and-sentinel idea, applied to a different scheduler, is its own subject.
What the offload broke
Two failures sit behind this change, one from the first attempt and one from the switch itself.
The root crontab came first, and it broke ownership. INC-044 records wp-config.php and .htaccess reverting to root:root; the sites returned 500 errors each time wp-cron.php ran under root. Running the schedule as www-data removed that cause.
The switch removed a trigger a plugin had relied on. After WP-Optimize flushes its page cache on a schedule, an automatic precache is meant to refill it. That precache depends on the HTTP WP-cron loop I had just disabled, so it never completed. INC-072 records the result: pages served uncached until the cache was rebuilt by hand, recurring roughly weekly, and first caught by an external crawl that logged 36 slow URLs and a loopback traffic spike showing pages served dynamically. The permanent fix is to drive the precache from system cron through WP-CLI instead of the HTTP loop; it is still open, and a manual weekly re-run is the interim mitigation.
What it costs to run
No figure is recorded for the time this costs, and I will not invent one. The upkeep is a post-flush precache-completion check added to the WordPress cron and cache monitoring, so a flush that leaves the cache cold is caught rather than left for a visitor to find. It is small, and it exists because the schedule now runs unattended. A system schedule is something a person owns and checks, not something that looks after itself.
What this does not solve
The offload does one thing, and stops there. It moves scheduled work off the visitor path, and that is what fixes the request-path latency and the unpredictability of inline firing. It does not make a plugin independent of the trigger it was built on. A precache like WP-Optimize’s still waits on a driver from system cron, and until that fix lands the cache can still be found cold. The offload removed the HTTP loop; rebuilding what depended on it is separate work.
The pattern generalizes to any box that carries more than one site: move the scheduled work off the request path, offset the sites so they do not collide, and run the jobs as the user that owns the files. Two adjacent disciplines are their own subjects: validating a web server’s configuration before every reload, and deciding what runs on local hardware versus in the cloud.
The trade-offs I accepted to run this stack are on the about page. If this is the shape of your problem, let’s talk.