# How to replace WP-Cron with a real cron job (and why you should)

> Replace WP-Cron with real cron to stop missed scheduled posts and late WooCommerce emails. Step-by-step crontab, WP-CLI and systemd timer setups that work.

Source: https://zopanel.net/blog/wp-cron-server-cron  
Updated: 2026-10-21

## Key takeaways

- WP-Cron only runs when someone visits, so quiet sites miss schedules and busy sites pay for extra requests.
- The standard fix is to set DISABLE_WP_CRON to true and have the server run wp cron event run --due-now on a schedule.
- Run the job as the site's own user, never root, and use flock to stop overlapping runs.
- ZoPanel can switch a site to a server-run systemd timer every 5 minutes with one switch.

To replace WP-Cron with real cron, add `define( 'DISABLE_WP_CRON', true );` to `wp-config.php`, then have the server run `wp cron event run --due-now` every few minutes as the site's own user, from crontab or a systemd timer. That's the whole fix. It matters because WP-Cron is not a real scheduler: WordPress only checks for due tasks **when someone loads a page**, so quiet sites miss scheduled posts and busy sites spend an extra PHP request on every check.

This guide explains how WP-Cron works, how to tell it's causing trouble, three ways to replace it (WP-CLI, an HTTP call, a systemd timer) with commands for Ubuntu and Debian, the mistakes to avoid, and how ZoPanel does it with one switch.

## How does WP-Cron work?

WordPress has plenty of scheduled work: publishing scheduled posts, checking for updates, cleaning up temporary data, sending order emails, running backup or SEO plugin jobs. Because WordPress has to run on shared hosting where you cannot always configure cron, it fakes one:

1. On every page load, WordPress checks its list of scheduled events (stored in `wp_options`).
2. If something is due, it fires a background "loopback" request to `https://your-site/wp-cron.php`.
3. That request runs the due events in a separate PHP process.

The [WordPress developer handbook](https://developer.wordpress.org/plugins/cron/hooking-wp-cron-into-the-system-task-scheduler/) is explicit that, unless you disable it, WordPress keeps running WP-Cron on each page load even when a system scheduler exists. It's a sensible default for beginners, but it ties your schedule to your traffic, and that is the root of every problem below.

## Why is WP-Cron slow or unreliable?

**Quiet sites.** No visits, no trigger. A post scheduled for 8:00 may go live at 10:30 when the first visitor shows up, and WordPress marks it "Missed schedule".

**Page caching starves it.** This one surprises people: pages served from an nginx or CDN cache never run PHP, so WordPress never gets the chance to check the schedule. The better your cache, the less often WP-Cron fires.

**Failed loopback requests.** The server must be able to call itself over HTTPS. Plenty of things break that: DNS that points through a proxy or not yet at the server, a firewall that blocks the server's own IP, password-protected directories, security plugins that block `wp-cron.php`, or a broken certificate. The "loopback request" test in WordPress's Site Health exists to catch exactly this.

**Overhead on busy sites.** Every time an event is due, another PHP request is spawned alongside the visitor's. If the domain resolves slowly, each call can tie up a PHP worker for 10 to 20 seconds. Across hundreds of sites on one server, those held workers add up to real memory and CPU pressure.

**Heavy jobs inside a web request.** Backup, newsletter and inventory-sync jobs running through `wp-cron.php` are subject to PHP-FPM and web server timeouts. Long jobs get cut off and start over next time.

## How do you know WP-Cron is the problem?

Typical symptoms:

- Scheduled posts showing "Missed schedule".
- Order emails or membership password resets arriving hours late.
- In WooCommerce, **Status → Scheduled Actions** lists many "Past-due" actions.
- Site Health reports a failed loopback request or a failed scheduled event.
- The access log is full of `POST /wp-cron.php` on a busy site.

With SSH access, WP-CLI gives the quickest answer. Run it in the site's directory as the user that owns the site:

```bash
wp cron test               # tests WP-Cron's spawning
wp cron event list         # events and their next run time
```

Events whose "next run" is long in the past mean WP-Cron is not being triggered regularly.

## How to replace WP-Cron with real cron

### Step 1: Stop visit-triggered cron

Open `wp-config.php` and add this line **above** `/* That's all, stop editing! */`:

```php
define( 'DISABLE_WP_CRON', true );
```

This only stops visits from triggering cron. Events are still scheduled; they just wait for something to run them. So **don't stop here**: without step 2, your site will never run scheduled tasks again.

### Step 2: Let the server run due events

Three options, in the order we recommend them.

**Option A: WP-CLI (recommended).** No HTTP, no web server timeouts, no dependence on DNS or SSL. Edit the crontab of **the user that owns the site** (not root):

```bash
sudo crontab -u webuser -e
```

Add:

```bash
*/5 * * * * cd /home/webuser/public_html && flock -n /tmp/wpcron-webuser.lock /usr/local/bin/wp cron event run --due-now --quiet
```

What each part does:

- `*/5 * * * *`: every 5 minutes.
- `cd ...`: into the WordPress directory (or use `--path=`).
- `flock -n`: if the previous run is still going, skip this one instead of overlapping.
- `wp cron event run --due-now`: runs every due event. The [WP-CLI documentation](https://developer.wordpress.org/cli/commands/cron/event/run/) notes it respects WordPress's `doing_cron` lock to prevent overlapping runs.

The path to `wp` depends on how you installed it; check with `which wp`. On multisite, add `--network` to cover every subsite.

**Option B: Call wp-cron.php over HTTP.** For when WP-CLI isn't available:

```bash
*/5 * * * * curl -fsS -o /dev/null --max-time 120 "https://example.com/wp-cron.php?doing_wp_cron"
```

This still depends on DNS, SSL and PHP-FPM timeouts, but at least the schedule no longer depends on visitors. Use the `https://` URL if the site forces HTTPS, since `curl` does not follow redirects without `-L`.

**Option C: A systemd timer.** On Ubuntu 24.04 and Debian 12, a timer gives you clearer logs (`journalctl`), no overlapping runs (a service runs one instance at a time) and `RandomizedDelaySec` to spread the load across many sites. Create `/etc/systemd/system/wpcron-example.service`:

```ini
[Unit]
Description=WordPress cron for example.com

[Service]
Type=oneshot
User=webuser
WorkingDirectory=/home/webuser/public_html
ExecStart=/usr/local/bin/wp cron event run --due-now --quiet
Nice=10
IOSchedulingClass=idle
```

and `/etc/systemd/system/wpcron-example.timer`:

```ini
[Unit]
Description=Run WordPress cron for example.com every 5 minutes

[Timer]
OnCalendar=*:0/5
RandomizedDelaySec=60
Persistent=true

[Install]
WantedBy=timers.target
```

Enable it:

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now wpcron-example.timer
systemctl list-timers | grep wpcron
```

### Step 3: Verify

```bash
sudo -u webuser wp --path=/home/webuser/public_html cron event list
```

After a few cycles, "next run" should always be in the near future. Schedule a test post ten minutes out and check that it goes live on time.

## Every 1, 5 or 15 minutes?

| Interval | Good for | Notes |
|---|---|---|
| 1 minute | Busy shops, large email queues | Costs the most; multiply by the number of sites on the server |
| 5 minutes | Most sites, including small and mid-size WooCommerce stores | Good balance; scheduled posts are at most 5 minutes late |
| 15 minutes | Blogs, brochure sites | The interval used in the WordPress handbook's example |

A note on WooCommerce: its Action Scheduler queue asks WP-Cron to run it every minute. With a 5-minute cron, queued actions wait up to 5 minutes, which is fine for most stores. Very busy shops can add a separate cron line that runs `wp action-scheduler run` every minute; the [Action Scheduler docs](https://actionscheduler.org/wp-cli/) recommend WP-CLI for large queues.

On a server with many sites, don't let every job fire at minute 0, 5, 10. Spread them out (with `RandomizedDelaySec` or offset minutes in crontab) to avoid synchronized load spikes.

## Common mistakes

- **Running cron as root.** WP-CLI refuses to run as root without `--allow-root`, for good reason: cache and log files created by the job end up owned by root and the site can no longer write them, and a malicious plugin would run with root rights. Always use the site's user.
- **Setting DISABLE_WP_CRON and forgetting the cron job.** The site silently stops all scheduled work. You find out a week later when there are no backups.
- **Putting DISABLE_WP_CRON after the "stop editing" line.** By then `wp-settings.php` has loaded and the constant has no effect.
- **No lock.** A heavy job (a backup) runs past 5 minutes, the next run piles on, and the server struggles.
- **Treating ALTERNATE_WP_CRON as the fix.** It redirects a visitor to run cron and is meant only for broken loopbacks with no other option. It still depends on visits.

## Replacing WP-Cron with one switch in ZoPanel

In ZoPanel, open the website, go to the **WordPress** tab and, in the **Maintenance** card, turn on **Server runs scheduled tasks (every 5 min, not on visits)**. As the [WordPress Toolkit documentation](/docs/wordpress) explains, ZoPanel then:

1. sets `DISABLE_WP_CRON` to `true` in `wp-config.php`;
2. creates a systemd timer that runs `wp cron event run --due-now` every 5 minutes **as the hosting account**, inside that account's resource limits and at low CPU and disk priority. Sites are spread across the interval, so a server hosting hundreds of them never sees all their jobs at once.

New WordPress installs made through ZoPanel use this mode from the start. Turning it off removes the timer, and removes `DISABLE_WP_CRON` only if ZoPanel added it; a site that disabled WP-Cron on its own keeps its setting.

This is also why ZoPanel's page cache doesn't cause missed schedules: cached pages no longer need to trigger WP-Cron because the server takes care of it. In our [measurements of how many WordPress sites a 4 GB VPS can host](/blog/how-many-wordpress-sites-4gb-vps), letting the server run scheduled tasks is one of the tips we drew from packing hundreds of sites onto one machine. See [PHP performance](/docs/php-performance) for how quiet sites' PHP is put to sleep while their scheduled tasks keep running.

## Conclusion

WP-Cron made sense for old-school shared hosting, but on a VPS you have real cron, and you should use it. Two steps are enough: stop visit-triggered cron, then have the server run `wp cron event run --due-now` on a schedule as the site's user. Scheduled posts go out on time, emails leave when they should, and every page view gets slightly lighter.

If you'd rather not create crontabs site by site, ZoPanel's Free plan includes the full WordPress Toolkit for 10 websites. See [pricing](/pricing) when you need more.

## Frequently asked questions

### Will disabling WP-Cron break my plugins?

No, as long as you replace it with server cron. Plugins schedule events exactly as before; only the trigger changes. If you disable WP-Cron without a replacement, every plugin's scheduled tasks stop.

### Why do my scheduled posts say "Missed schedule"?

Because nothing ran PHP at the right moment, usually because the site is quiet or its pages are served from cache. A failing loopback request to `wp-cron.php` causes the same symptom. A server cron job every 5 minutes fixes both.

### Should I use wget/curl or WP-CLI in cron?

WP-CLI whenever you can: it runs directly in PHP CLI, outside the web server, so it doesn't depend on DNS, SSL or PHP-FPM timeouts. Calling `wp-cron.php` with curl is the fallback when WP-CLI isn't installed.

### What if I'm on shared hosting without SSH?

Most hosting panels have a cron jobs page in the interface; use the curl or wget call to `wp-cron.php` from option B. If your host runs ZoPanel, just turn on **Server runs scheduled tasks** on the WordPress tab.

### Does running cron every minute slow the server down?

For a single site, barely: with nothing due, the command exits quickly. On a server with hundreds of sites, each run still loads WordPress, so 5 minutes is a better balance, with jobs spread across the interval.
