Replace WP-Cron with a real server cron job

WP-Cron riding on visitor traffic is fine until it isn't: scheduled jobs slip, PHP workers tie up on the loopback POST, and on quiet sites events stack up for days. This article walks the production-grade replacement: disable the in-WordPress trigger with DISABLE_WP_CRON, then run the WordPress cron from a real cron daemon (cPanel, crontab + WP-CLI, crontab + curl, or a Kubernetes CronJob) and verify it is actually firing.

Goal. Stop WP-Cron from running off visitor page loads, and run the WordPress cron code from a real scheduler instead. By the end of this article you have one of three working setups: a cPanel cron job hitting wp-cron.php over HTTPS, a Linux crontab entry calling WP-CLI directly, or a Kubernetes CronJob running wp-cli in a one-shot pod. You also know exactly how to verify the new runner is firing on schedule, what stays accessible after the switch (it is not "everything"), and which interval choice actually matters.

Prerequisites

  • A WordPress site. This applies to any WordPress install from at least 5.x onwards. The DISABLE_WP_CRON constant has been part of WordPress core for many years; the Plugin Handbook entry on hooking WP-Cron into the system task scheduler is the canonical primary source for the configuration.
  • Access to wp-config.php. Through your host's file manager, SFTP, or SSH. This is the only file you have to edit on the WordPress side.
  • Access to a scheduler. One of: cPanel's "Cron Jobs" tool, a Linux crontab on the host (typically as the web server user, www-data on Debian/Ubuntu), or a Kubernetes cluster where the WordPress install runs.
  • WP-CLI 0.24.0 (July 2016) or newer, if you take the WP-CLI path. The 0.24.0 release defines the DOING_CRON constant before WordPress is loaded for wp cron event run and adds the --due-now flag that this article uses, per the WP-CLI 0.24.0 release notes. Earlier versions had wp cron event run but with the DOING_CRON gap that breaks plugins which guard expensive code with if ( defined( 'DOING_CRON' ) ). Any WP-CLI shipped in the last several years is fine.
  • A site that depends on WP-Cron. If you have already migrated to a custom Action Scheduler runner or an external job queue, the picture is more complicated and this article is the wrong starting point.

This is the practical replacement guide. For the why behind it (what WP-Cron actually is, the five failure modes, and how to diagnose a broken runner), the conceptual companion is WordPress wp-cron: why it fails and how to replace it.

What WP-Cron is and why it falls short under load

WP-Cron is a pseudo-scheduler that runs whenever a visitor loads a page. WordPress bootstraps, calls spawn_cron(), and if events are due it fires a non-blocking HTTP POST back to wp-cron.php on the same host. That wp-cron.php request runs whatever hooks are due and exits. The mechanism is described in the WordPress Plugin Handbook on cron.

This works on a busy site that gets a request every few seconds. It falls apart on every other shape of site:

  • Low-traffic sites. A staging environment, a B2B portal, a knowledge base for paying customers, a brochure site. All of them can sit idle for hours. While they do, scheduled posts do not publish, plugin update checks do not run, transient cleanups skip, and on WooCommerce sites the Action Scheduler queue (which hooks into WP-Cron by default) starts piling up.
  • Sites behind aggressive full-page caching. A request served from Varnish, an nginx fastcgi_cache, LSCache, or Cloudflare Cache Reserve never reaches PHP. WordPress never bootstraps. No cron spawn happens. The site can serve thousands of cached pages an hour while every scheduled job quietly misses its window.
  • Sites where the loopback HTTP request is blocked. BasicAuth covering the whole site, a security plugin that blocks "server to itself" requests, Cloudflare bot rules dropping origin-IP traffic, a TLS handshake failure inside the host's network. The page load completes normally, the visitor sees no error, and the cron silently does not run.
  • High-traffic sites. Each spawn_cron() call ties up a PHP worker for the duration of the loopback POST. PHP-FPM softens this with fastcgi_finish_request(), but the cost is non-zero, and on a tight worker pool a long-running cron event can push concurrent traffic into the queue. The performance side of this story lives in the companion article on PHP-FPM tuning for WordPress.

A real system cron is immune to all four. It runs on its own clock, in its own process, off the request path. That is the entire point.

The wp-cron.php endpoint stays public after you disable WP-Cron

This is the misconception that wastes the most time, so it goes at the top.

Setting define( 'DISABLE_WP_CRON', true ); in wp-config.php does not make wp-cron.php inaccessible. The constant only stops spawn_cron() from firing on page loads. The file wp-cron.php still sits in your WordPress root, still answers HTTP requests, still runs whatever events are due if you call it directly. The replacement runner you are about to add depends on this: the cPanel cron job and the crontab + curl pattern both work by calling wp-cron.php over HTTPS.

Two practical implications:

  • If you wanted to stop people from triggering cron events from outside, disabling WP-Cron does not do that. To block external calls you need a webserver-level rule (location = /wp-cron.php { allow 127.0.0.1; deny all; } in nginx, or an .htaccess block in Apache). And if you do block it externally, the cPanel-style cron has to come from a request that does not get blocked, usually one originating on the same host.
  • If you wanted to remove wp-cron.php "to disable cron", do not. WordPress and many plugins still rely on the file existing. Just set the constant and leave the file alone.

The Plugin Handbook confirms this design: WordPress will continue to run WP-Cron on each page load by default, and disabling it via the constant requires you to schedule the task externally yourself, per the system task scheduler guide.

Step 1: disable the built-in WP-Cron in wp-config.php

Open wp-config.php (via your host's file manager, SFTP, or SSH) and add this line above the comment /* That's all, stop editing! Happy publishing. */:

// Stop wp-cron from firing on every page load.
// You MUST add an external runner below or scheduled jobs will never run.
define( 'DISABLE_WP_CRON', true );

That is the entire change to WordPress. No database edit, no plugin to install. The constant is read by core's wp_doing_cron() and _wp_cron_lock() machinery; once it is set, spawn_cron() returns early on every page load.

Save the file. Reload the site once and confirm it still loads normally. Nothing visible changes at this stage. From this moment until you add a runner, scheduled events will accumulate without firing.

Do not skip Step 2. A site running with DISABLE_WP_CRON and no replacement runner is worse than vanilla WP-Cron: scheduled posts stop publishing, security and update checks freeze, WooCommerce subscriptions silently break, transient cleanup never happens. If you only get partway through this article, undo Step 1 with define( 'DISABLE_WP_CRON', false ); (or remove the line) and pick this back up when you have time to finish.

Step 2: add a real system cron job

Pick the path that matches what you actually have. You only need one.

Path A: cPanel (or any hosting panel with a cron UI)

This is the fallback for shared hosting and managed environments where you do not get SSH or WP-CLI. In cPanel, open Cron Jobs, scroll to "Add New Cron Job", and fill in:

  • Common Settings: pick "Every minute (* * * * *)" or "Twice per hour (*/30 * * * *)" depending on your sites. The interval choice is discussed below.
  • Command:
# cPanel cron command, runs every minute
curl -sS --max-time 30 -o /dev/null "https://yoursite.nl/wp-cron.php?doing_wp_cron=1"

Why curl and not wget? Both work. curl is more often pre-installed on managed hosts and produces clearer error output. --max-time 30 caps the request at 30 seconds so a stuck cron run cannot keep stacking new requests. -sS makes curl silent on success but still print errors, so when something goes wrong the output ends up in the cron mail. -o /dev/null discards the response body. The ?doing_wp_cron=1 query parameter is a hint to WordPress; the actual lock value comes from a 22-digit microtime that core generates internally.

If your host's loopback is blocked from origin-IP traffic (Cloudflare bot rules are the most common cause), you may need to ask support to whitelist wp-cron.php for origin requests. The symptom is a 403 or 503 in the cron mail. The Advanced Administration Handbook on loopbacks covers what can break loopback requests inside a host.

Verification (reproduced under Step 3): after the first scheduled minute passes, run a wp cron event list --due-now if you have WP-CLI, or schedule a test post for two minutes from now and watch whether it publishes on time.

Path B: Linux crontab + WP-CLI (the most reliable option)

If you have SSH access and WP-CLI installed, this is the option to take. WP-CLI runs the WordPress bootstrap directly in a PHP CLI process, which means no HTTP loopback, no TLS handshake, no firewall, no BasicAuth. The whole class of "the loopback is blocked" failures cannot apply.

Run crontab -e as the user that owns the WordPress files (often www-data on Debian/Ubuntu, or a dedicated site user on managed hosts). Append:

# Run WordPress cron events every minute via WP-CLI.
# --quiet keeps the cron mailbox empty unless something fails.
* * * * * /usr/local/bin/wp cron event run --due-now --path=/var/www/yoursite.nl/htdocs --quiet > /dev/null 2>&1

Field by field, this is the standard Linux crontab(5) five-field format: minute, hour, day of month, month, day of week. * * * * * means "every minute".

Three reasons WP-CLI is the right tool here:

  • No loopback. WP-CLI bootstraps WordPress in-process. It does not make an HTTP request to itself. Whatever was breaking the HTTP path is no longer in scope.
  • --due-now runs only what is due. WP-CLI 0.24.0 added --due-now, and the wp cron event run reference is the authoritative documentation. --due-now is what you want for a runner that fires every minute: it iterates the events whose next_run is in the past, runs them, and exits.
  • DOING_CRON is set correctly. On WP-CLI 0.24.0 and newer, the DOING_CRON constant is defined before WordPress is loaded for wp cron event run. Plugins that gate expensive code on defined( 'DOING_CRON' ) (caching skips, asset minification skips, debug logging) will see the constant and behave as they would inside a real cron run. Earlier WP-CLI versions had a bug here, fixed in 0.24.0.

About --quiet: the cron daemon mails the output of every job to the local user mailbox. Without --quiet, you get a mail every minute with WP-CLI's "Executed the cron event 'X'" lines. Combined with > /dev/null 2>&1 redirection, only catastrophic errors (like the WP-CLI binary missing) reach the mailbox. That is what you want.

If you run multiple WordPress sites on the same host, give each one its own crontab line with a different --path=. Do not loop them in shell; you want each site's cron to be visible and independently editable.

Path C: Linux crontab + curl (when WP-CLI is not available)

Some VPS environments and shared SSH plans do not have WP-CLI installed and do not let you install it. In that case, fall back to the same shape as the cPanel cron but in crontab:

# Run WordPress cron via the loopback every minute.
* * * * * /usr/bin/curl -sS --max-time 30 -o /dev/null "https://yoursite.nl/wp-cron.php?doing_wp_cron=1" > /dev/null 2>&1

This is functionally identical to Path A, just expressed in crontab syntax. Use it when WP-CLI is not on the path. Prefer Path B when it is.

Step 3: verify the replacement is firing

Verification is non-optional. A cron job that was accepted by the scheduler is not the same as one that runs successfully. Three checks, in increasing order of authority.

1. The scheduled-post smoke test. In wp-admin, schedule a draft post for three minutes from now. Wait. Refresh the front page after the scheduled time. If the post is live, the runner is firing. This is the simplest end-to-end test and the one I do first every time.

2. WP-CLI overdue check (any path with WP-CLI access, including over SSH on the host):

# Run from the WordPress site root, or pass --path=
wp cron event list --due-now

Run it once, wait two minutes, run it again. If overdue events disappear, the runner is firing. If the same events keep showing up, the runner is not running them and you go back to Step 2 to figure out why. Add --fields=hook,next_run_relative for a more readable view.

The full command reference is in the WP-CLI cron commands documentation. For the underlying WP-Cron diagnostics including Site Health and the WP Crontrol plugin, see WordPress wp-cron: why it fails.

3. The cron mailbox check (Path B and C only). On Linux, the cron daemon mails the output of every job to the local user. Read it with mail or less /var/mail/www-data (substitute the user that owns the crontab). With --quiet and > /dev/null 2>&1 in place, the mailbox should stay empty during normal operation. If new mails are arriving, the cron job is running but its command is failing. Read the mail to see why; common causes are WP-CLI not on $PATH (use the absolute path /usr/local/bin/wp in the crontab, not wp) or --path pointing at the wrong directory.

For a WooCommerce site, also check Action Scheduler. In wp-admin, go to Tools → Scheduled Actions. Sort by the "Scheduled" column and look for rows with dates in the past. If new rows keep moving into "Completed" after your runner went live, Action Scheduler is catching up through the WP-Cron path you just replaced.

Special case: WordPress in Docker or Kubernetes (WP-CLI as a CronJob)

If WordPress runs in a container, the production-grade pattern is the same idea expressed differently: run wp cron event run --due-now on a schedule, but as a separate one-shot pod, not as a sidecar inside the WordPress pod.

This matters because a sidecar runs continuously and the cron daemon inside it is now another process to keep healthy, with its own logging and its own failure modes. A Kubernetes CronJob resource is a much better fit: the cluster's controller fires the schedule, runs the pod to completion, garbage-collects the result, and surfaces failures through standard pod-status APIs.

A minimal CronJob for a WordPress install where the WP-CLI image has access to the same persistent volume as the WordPress pods looks like this:

# k8s/wp-cron.yaml: every minute, run WordPress cron events that are due
apiVersion: batch/v1
kind: CronJob
metadata:
  name: wp-cron
  namespace: wordpress
spec:
  schedule: "* * * * *"
  # Forbid concurrent runs: skip a tick rather than stack pods if a run is slow.
  concurrencyPolicy: Forbid
  # Tight backoff so a misconfigured CronJob fails fast instead of retrying for an hour.
  startingDeadlineSeconds: 60
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: wp-cli
              image: wordpress:cli   # Alpine-based WP-CLI image; pin a digest in production
              args:
                - wp
                - cron
                - event
                - run
                - --due-now
                - --path=/var/www/html
              # UID 33 matches Debian www-data, which is what the wordpress:php-apache image uses.
              # The Alpine www-data is UID 82; without this, the CronJob will write files the
              # WordPress pods cannot read or modify.
              securityContext:
                runAsUser: 33
                runAsGroup: 33
              volumeMounts:
                - name: wp-content
                  mountPath: /var/www/html
              env:
                - name: WORDPRESS_DB_HOST
                  value: mariadb
                - name: WORDPRESS_DB_USER
                  valueFrom:
                    secretKeyRef:
                      name: wordpress-db
                      key: username
                - name: WORDPRESS_DB_PASSWORD
                  valueFrom:
                    secretKeyRef:
                      name: wordpress-db
                      key: password
                - name: WORDPRESS_DB_NAME
                  value: wordpress
          volumes:
            - name: wp-content
              persistentVolumeClaim:
                claimName: wp-content

The concurrencyPolicy: Forbid line is the key one. The Kubernetes CronJob documentation explains the three options: Allow (default, stacks pods if a run is slow), Forbid (skip the next tick if the previous run is still going), and Replace (kill the running pod and start a new one). For a one-minute schedule, Forbid is the right default. If a cron run takes longer than 60 seconds, you want the next minute to be skipped, not to launch a competing run that fights the first one.

The Alpine vs Debian UID trap is the same one I documented in the WordPress Docker setup article. The wordpress:cli image runs Alpine and its www-data user is UID 82. The wordpress:php-apache image runs Debian and its www-data is UID 33. If the CronJob writes files as UID 82 and the WordPress pods serve them as UID 33, you get permission errors that look random because they only surface for files that cron actually creates (plugin updates, transient files, generated assets). Fix it once with runAsUser: 33 in the security context.

The same persistent volume must be mounted into both the WordPress deployment and the CronJob, with accessModes that allow ReadWriteMany if the deployment scales beyond one replica. On a single-replica install, ReadWriteOnce is fine.

Apply with kubectl apply -f k8s/wp-cron.yaml and verify with:

kubectl -n wordpress get cronjob wp-cron
kubectl -n wordpress get jobs --watch
kubectl -n wordpress logs -l job-name=wp-cron-<pod-id>

The first command shows the schedule and LAST SCHEDULE. The second shows pods being created and completing. The third shows the WP-CLI output of a specific run. If LAST SCHEDULE keeps moving forward but jobs do not appear, check concurrencyPolicy and the cluster's CronJob controller logs.

Troubleshooting: tasks still not running after the switch

Cron mail every minute saying wp: command not found (Path B). The wp binary is not on the cron daemon's PATH. Fix this by using the absolute path in the crontab line, not wp. Run which wp from a normal shell to find it (commonly /usr/local/bin/wp) and substitute that in.

Cron mail saying Error: This does not seem to be a WordPress installation. Pass --path=<path> ... (Path B). The crontab is running from the wrong directory. Either add --path=/full/path/to/wordpress to the WP-CLI invocation (recommended), or cd into the WordPress root in the cron line itself. The first is more explicit and less brittle.

curl cron job returns HTTP 403 or 503 (Path A or C). The host's loopback is blocked from origin-IP traffic. This is the most common cause on hosts behind Cloudflare. Either ask the host to whitelist wp-cron.php for origin requests, or switch to Path B (WP-CLI) which sidesteps the HTTP path entirely. The Advanced Administration Handbook on loopbacks is the primary source on what blocks loopback HTTP inside a hosting environment.

Scheduled posts publish but Action Scheduler is still piling up (WooCommerce sites). Action Scheduler runs its own queue in addition to the WP-Cron path. If the runner is firing but Action Scheduler is not catching up, check that action_scheduler_run_queue is registered in the cron event list (wp cron event list | grep action_scheduler). If it is registered and still not running, the cause is in Action Scheduler, not the runner. Bump --max-time if your curl-based cron is timing out before Action Scheduler finishes a batch.

Cron runs but events still show as overdue. A specific event is failing inside its hook callback. Run it manually with wp cron event run <hook-name> to see the error directly. Common causes: an external API the event calls is down, a plugin that registered the event was deactivated and the event still exists, or the hook callback throws a fatal that the cron mail does not surface. For the broader pattern of stuck events, see WordPress wp-cron: why it fails.

Kubernetes CronJob: LAST SCHEDULE advances but no pods exist. Check concurrencyPolicy and the cluster CronJob controller logs (kubectl -n kube-system logs -l app=kube-controller-manager). The most common cause is startingDeadlineSeconds being too small for a busy cluster: if the controller cannot start the pod within the deadline, the run is skipped.

Three misconceptions worth correcting

"Disabling WP-Cron means scheduled posts stop publishing." Only if you do not add a real runner. The scheduled events live in the cron option in wp_options. Disabling WP-Cron only removes the trigger that runs them. As soon as a system cron picks up the same WordPress codebase, those events run on schedule again, including scheduled posts.

"wp-cron.php becomes inaccessible once WP-Cron is disabled." It does not. wp-cron.php is still publicly reachable over HTTPS, and it still runs whatever events are due if you call it directly. The DISABLE_WP_CRON constant only disables the in-WordPress trigger that fires from spawn_cron() on page loads. If you want to actually block external access to the file, that is a webserver-level rule, not a WordPress one.

"You need a one-minute cron interval." You do not, necessarily. The trade-off is real: a one-minute interval respects the shortest registered event interval (WordPress core's defaults start at hourly, but plugins can register five-minute or one-minute schedules through the cron_schedules filter, and Action Scheduler defaults to running its queue every minute). A five-minute interval is fine on a site without sub-five-minute scheduled events, and produces less log noise. A 15-minute interval is too long for any non-trivial site: scheduled posts can be published 15 minutes late, password-reset email queues get backed up, and Action Scheduler stops feeling responsive. Pick one minute for sites with WooCommerce or any newsletter/notification plugin; pick five minutes for low-traffic content sites; do not pick anything longer without a specific reason.

When you are ready to make the WordPress request path itself behave differently in production (full-page caching, FPM tuning, the broader cluster of "site is fast for visitors but cron stays reliable" problems), the next reads are PHP-FPM tuning for WordPress and the wp-cron concept article. For the staging environment side of validating any of this before production, see WordPress staging environment.

Done chasing slowdowns?

Performance issues tend to come back after quick fixes. WordPress maintenance keeps updates, caching and limits consistent.

See WordPress maintenance

Search this site

Start typing to search, or browse the knowledge base and blog.