How to run WordPress scheduled tasks with real cron instead of WP-Cron

By default, WordPress only runs its scheduled tasks when someone visits the site. Let the server run them on a clock instead, so posts publish on time and update checks never wait for a visitor.

Set up the new job first, then turn WP-Cron off. If you only do the second part, scheduled tasks stop running at all: no scheduled posts, no update checks, no plugin jobs.

What WP-Cron is, and why replace it

WordPress has a list of tasks to run later: publish a scheduled post, check for updates, delete old data, send a plugin's emails. This is WP-Cron. It has no clock of its own. On each page visit, WordPress checks whether a task is due, and if so, it calls its own wp-cron.php over the web to run it.

  • On a quiet site, tasks run late. A post set for 9:00 goes out when the first visitor comes, maybe at noon. With no visitors, it may show "Missed schedule".
  • On a busy site, it adds work. Visits keep checking the list and starting extra requests.
  • It needs the site to reach itself. If the server can't open its own address (a firewall, a login wall, a wrong DNS entry), tasks never run.

The fix: tell WordPress not to start tasks on page visits, and let the server run them every few minutes with WP-CLI. The WordPress handbook describes the same idea.

Before you start

  • The examples use a site called shop.example. It runs as the Linux user shop, and WordPress is in /var/www/shop.example/public. Use your own names. Use the same user that PHP-FPM runs the site as (see one PHP-FPM pool per site).
  • You need WP-CLI. Find its full path, you'll need it below:
    command -v wp
    If you followed the WP-CLI install steps, it's /usr/local/bin/wp.
  • Run wp commands as the site's user, never as root. More on that in our WordPress hardening guide.
  • We tested every step on Ubuntu 24.04 (PHP 8.3) and Debian 13 (PHP 8.4) with WordPress 7.1.

1. Look at the task list

cd /var/www/shop.example/public
sudo -u shop -- wp cron event list --fields=hook,next_run_relative,recurrence

You'll see WordPress's own tasks, such as wp_version_check and wp_update_plugins, and any that your plugins add. Tasks that say now are due and waiting for a visitor.

2. Run the due tasks by hand

sudo -u shop -- /usr/local/bin/wp --path=/var/www/shop.example/public cron event run --due-now

Each task prints an "Executed" line, and the last line counts them. Run it again and it says Executed a total of 0 cron events, because nothing is due any more. This is the exact command the server will run. --path tells WP-CLI where WordPress is, so the job doesn't depend on the folder it starts in.

3. Pick one: a cron job or a systemd timer

Both work. Pick one, not both. Every 5 minutes is a good default: a scheduled post then goes out at most 5 minutes late. Use every minute if that matters to you.

Option A: cron. Open the site user's crontab:

sudo crontab -u shop -e

Add this line (one line), save and close:

*/5 * * * * /usr/local/bin/wp --path=/var/www/shop.example/public cron event run --due-now --quiet >> /var/www/shop.example/wp-cron.log 2>&1
  • Full paths everywhere. Cron starts with a short PATH and a different folder from your shell.
  • --quiet hides the "Executed" lines, so the log only gets errors and stays small.
  • The log lives outside public, so nobody can read it over the web. The site user must be able to write there.

Option B: a systemd timer. More files, but every run is logged in journalctl, and you can see when it last ran. It's the same pattern as in our Laravel scheduler guide. Create /etc/systemd/system/shop-wp-cron.service:

[Unit]
Description=WordPress scheduled tasks for shop

[Service]
Type=oneshot
User=shop
Group=shop
ExecStart=/usr/local/bin/wp --path=/var/www/shop.example/public cron event run --due-now

And /etc/systemd/system/shop-wp-cron.timer:

[Unit]
Description=Run WordPress scheduled tasks for shop every 5 minutes

[Timer]
OnCalendar=*:0/5
AccuracySec=1s
Persistent=true

[Install]
WantedBy=timers.target

OnCalendar=*:0/5 means every 5 minutes. AccuracySec=1s stops systemd from starting it up to a minute late. Persistent=true runs a missed turn right after the server boots. Turn it on:

sudo systemctl daemon-reload
sudo systemctl enable --now shop-wp-cron.timer
systemctl list-timers shop-wp-cron.timer

No --quiet here: the journal keeps the output, so you can read what ran (step 6).

4. Turn off WP-Cron

Now that the server runs the tasks, stop page visits from starting them:

sudo -u shop -- wp config set DISABLE_WP_CRON true --raw

This adds define( 'DISABLE_WP_CRON', true ); to wp-config.php. --raw matters: without it, the value is the text 'true' instead of the real value. It only stops visits from starting tasks. The tasks themselves and wp cron event run work as before.

5. Check that it works

WP-CLI's own check now reports that spawning is off. That's what you want:

sudo -u shop -- wp cron test
Error: The DISABLE_WP_CRON constant is set to true. WP-Cron spawning is disabled.

The task list: run wp cron event list from step 1 again after the next turn. Nothing should say now for longer than 5 minutes.

The real test: a scheduled post. Schedule a draft a few minutes ahead in the editor, or with WP-CLI. The time is in the site's timezone (Settings → General):

sudo -u shop -- wp post create --post_title="Cron test" --post_status=future --post_date="2026-11-12 16:01:00"

Don't open the site. After the next run, check:

sudo -u shop -- wp post list --post_status=publish --fields=ID,post_title,post_date

In our tests, the post went out at the first run after its time, on both servers, with no visit at all: the web server's access log stayed empty. Delete the test post afterwards with wp post delete ID --force.

6. Read the log

Cron: the log file only fills up when something goes wrong.

tail /var/www/shop.example/wp-cron.log

Timer: each run, with the tasks it ran:

journalctl -u shop-wp-cron.service --since "1 hour ago"

A failed run shows as Failed with result 'exit-code', and systemctl status shop-wp-cron.service shows the last error.

Why not just call wp-cron.php with curl?

Many guides use a cron line like this instead:

*/5 * * * * curl -s -o /dev/null https://shop.example/wp-cron.php

It works, even with DISABLE_WP_CRON set. But each run goes through the web server and PHP-FPM, with the web request's time limit, and it fails if the site is down, behind a login or has a broken certificate. WP-CLI runs PHP directly, as the site's user, with no web request. Keep curl as a fallback for servers where you can't install WP-CLI.

Several sites in one install (multisite)

In a multisite network, each site has its own task list, and wp cron event run only runs the main site's. Run it for every site:

/usr/local/bin/wp --path=/var/www/shop.example/public site list --field=url | xargs -I{} /usr/local/bin/wp --path=/var/www/shop.example/public --url={} cron event run --due-now --quiet

Put that line into the crontab, in place of the one from step 3 (keep the */5 * * * * and the log part). For the timer, systemd needs a shell to run the |, so the service's line becomes:

ExecStart=/bin/sh -c '/usr/local/bin/wp --path=/var/www/shop.example/public site list --field=url | xargs -I{} /usr/local/bin/wp --path=/var/www/shop.example/public --url={} cron event run --due-now'

We tested both on a network with two sites: the plain command only ran the main site's tasks, and this one ran both. With many sites, one run takes longer, so check that it finishes well within 5 minutes.

If something goes wrong

  • Posts show "Missed schedule": the job isn't running. Check sudo crontab -u shop -l or systemctl list-timers shop-wp-cron.timer, and look at the log from step 6. Run the command from step 2 by hand to see the error.
  • "/bin/sh: 1: wp: not found" in the log: cron doesn't look in /usr/local/bin. Use the full path to wp, as in step 3.
  • A task works on the site but fails from the job: WP-CLI uses PHP's command-line version, not PHP-FPM. They have separate settings and extensions. On Ubuntu and Debian the command line has no memory limit and the website has 128M, so a task can also work from the job but fail on the site. Compare php -v and php -m with what the site uses, and see our PHP upgrade guide if the versions differ.
  • "Error establishing a database connection" or permission errors: the job runs as the wrong user. Run it as the user that owns the files, not root.
  • A plugin still says it needs WP-Cron or a "loopback request": some plugins start background work by calling the site over the web, and Site Health tests this too. That's separate from WP-Cron and still needs the site to reach its own address. Check with curl -I https://shop.example/ on the server.
  • Tasks run twice: you set up both cron and the timer, or another server runs the same site. Keep one.

Official sources

Next steps: work through our guide to running WordPress on your own server, and back up the site on a schedule too.

← Back to the security blog