WP-Cron not running behind Cloudflare: cron_request filter and the https redirect
|

WP-Cron Not Running Behind Cloudflare or a Proxy? The http Loopback Redirect Problem

If scheduled posts show “Missed schedule”, backups do not run, or cache and sitemap refreshes never happen, WP-Cron may be failing without any error. One cause to rule out on a site behind Cloudflare or a reverse proxy is that WordPress sends its cron trigger to an http:// address, the proxy or CDN answers with a redirect to https://, and WordPress never follows it. This guide shows how to look at the URL WordPress really sends, how to force HTTPS with a three-line filter, and how to run cron from the system scheduler instead. I found the behaviour while auditing cron on a WordPress 7.1.3 site behind Cloudflare, with WordPress running in a Docker container behind a proxy that ends HTTPS. I did not prove that it caused a missed schedule on that site, so treat the filter as a safeguard, and the tests below as a way to check your own setup.

How WP-Cron works

WP-Cron is not a real scheduler. According to the official documentation, it runs only on page load: WordPress checks its list of scheduled tasks when a page is requested, and a task scheduled for 2:00 PM may not run until the next visit. To avoid slowing the visitor down, WordPress starts the work with a separate request to wp-cron.php on your own site, a “loopback”. The cron_request hook documentation says this request is non-blocking, with a time-out of 0.01 seconds and no wait for an answer, so WordPress’s HTTP call does not look at the response or follow a redirect.

The symptom

  • Scheduled posts stay on “Missed schedule” until something else triggers them.
  • Tasks such as backups, cache clean-ups or sitemap refreshes run late or never.
  • Site Health under Tools reports problems with scheduled events or loopback requests.

Step 1: see which URL WordPress builds

WordPress builds the loopback address with site_url(). When no scheme is passed, that function chooses HTTPS only if is_ssl() is true for the current request, and otherwise it produces http://, replacing the scheme stored in your site address setting. Behind a proxy that ends HTTPS, a request that does not carry the proxy’s HTTPS flag can therefore give an http:// address even though the setting says https://. WP-CLI and requests that go straight to the container are examples. Normal visitor requests usually carry the forwarded-protocol header, and a wp-config.php block that sets $_SERVER['HTTPS'] from it makes is_ssl() true for them. Only do that if the header comes from a proxy you control that overwrites any value sent by the client, because otherwise a visitor could fake it.

On my site, a small script run through PHP’s command line showed exactly this difference:

siteurl option: https://sysadmin.co.in
is_ssl(): false
site_url(wp-cron.php) in this context: http://sysadmin.co.in/wp-cron.php

To see what your own site sends, log the URL temporarily with a filter at a very late priority (remove it afterwards):

add_filter('cron_request', function ($request) {
    error_log('cron_request url: ' . $request['url']);
    return $request;
}, 99999);

Step 2: see what the edge does with an http:// request

Request wp-cron.php over HTTP and over HTTPS and compare the first lines of each response. Replace the domain with yours:

curl -s -o /dev/null -D - "http://example.com/wp-cron.php" | head -5
curl -s -o /dev/null -D - "https://example.com/wp-cron.php" | head -5

These are the results from my site (the commands used a ?doing_wp_cron=1 suffix, which is fine for checking the redirect but must not be used in a real cron job, as explained below):

HTTP/1.1 302 Found
Location: https://sysadmin.co.in/wp-cron.php?doing_wp_cron=1
Content-Length: 5

HTTP/1.1 200 OK
Cache-Control: no-cache, must-revalidate, max-age=0
Content-Length: 0
Content-Type: text/html; charset=UTF-8

These two requests only show how the edge treats the two schemes. WordPress sends a POST and a lock token, so a clean 200 here does not prove that cron events run. Together with Step 1, though, they show the risk: a loopback built with http:// gets a redirect, and WordPress’s non-blocking call never follows it.

Step 3: force https for the cron request

The cron_request filter receives an array with the keys url, key and args and lets you change the request before it is sent. Change the scheme to HTTPS. Put this in a must-use plugin, for example wp-content/mu-plugins/cron-https.php:

<?php
add_filter('cron_request', function ($request) {
    $request['url'] = set_url_scheme($request['url'], 'https');
    return $request;
});

Only use this if your site is served over HTTPS. A PHP error in a must-use plugin can take the site down, so keep SFTP, SSH or docker exec access ready to delete the file if needed.

To test it, schedule a post a few minutes ahead, wait until it is due, and then load an uncached front-end page. That page load starts a separate cron request, which can take a moment or be held back by a lock, so wait a short while and then check whether the post has published. If it has not, look at the other causes below, or run cron from the system scheduler.

Alternative: run cron from the system scheduler

For a quiet site, or one where scheduled work matters, the usual approach is to stop WordPress from starting cron on page loads and run it on a timer. This depends on traffic in the first case and not in the second, which is the real difference between the two. Add the first line to wp-config.php only after the timer works:

define('DISABLE_WP_CRON', true);

Then request wp-cron.php every five minutes from the server’s crontab. Do not add ?doing_wp_cron=1 to this URL. WordPress treats a non-empty doing_wp_cron value as a lock token, and when it does not match the stored lock, wp-cron.php returns without running anything.

*/5 * * * * curl -fsS --max-time 60 -o /dev/null "https://example.com/wp-cron.php"

The -f flag makes curl fail on an HTTP error, and -S keeps its error message visible even with -s, so a broken job does not stay silent. A successful HTTP response does not by itself prove that events ran, so before you set DISABLE_WP_CRON, check that a scheduled post or a known event is really being processed by the timer.

If WP-CLI is installed where you run the job, wp cron event run --due-now does the same work without an HTTP request; for a Docker container, run it with docker exec as the web server’s user. Events that are still pending when a run is missed remain due and are picked up the next time the job runs successfully. That does not recreate work that never happened, for example a backup snapshot that should have been taken at the missed time.

Other causes worth ruling out

  • Basic authentication or an IP allow-list on the site, which blocks the loopback request.
  • A firewall or security plugin that blocks requests from the server to itself.
  • DISABLE_WP_CRON set with no system job, so nothing triggers cron at all.
  • A page cache that serves wp-cron.php from cache. It should be excluded.

Common questions

Is the redirect from Cloudflare or from my server?

It can come from either layer, such as an “Always Use HTTPS” setting at Cloudflare or a redirect in your proxy. The filter does not depend on which one it is, because the request goes straight to HTTPS.

Does this affect plugins that schedule work?

Plugins that depend on WP-Cron, including those using Action Scheduler, can run late when the trigger fails. Action Scheduler also has other ways to run its queue, so the effect varies.

Will opening the dashboard run my tasks?

Any uncached page load starts the WP-Cron check, so a visit can start tasks that are due. That is why a quiet site can look stuck until someone visits.

Sources

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *