WP Super Cache Expert Mode Behind a Reverse Proxy: The X-Forwarded-Proto Rewrite Fix
WP Super Cache can serve a page straight from a static file, without starting PHP, but that “expert” mode only works if Apache’s rewrite rules recognise your HTTPS requests. If a reverse proxy ends TLS and talks to Apache over plain HTTP, as Traefik does in Coolify, Apache sees HTTP and the HTTPS rules the plugin writes can never match, so every request still goes through PHP. This guide shows the one change that fixed it for me, plus the other problems I ran into on the way: a cache folder with the wrong owner, an empty advanced-cache.php, and rewrite rules generated from the command line with a doubled file path. On my site the same cached article went from about 0.32 seconds to about 0.02 seconds at the origin. The setup was WordPress 7.1.3 on the official Apache Docker image, WP Super Cache 3.1.4, Traefik in front and Cloudflare in front of that.
The three delivery modes
As the plugin’s FAQ explains, WP Super Cache can deliver cached pages in three ways. Simple mode serves them from PHP, which is the easy option and needs no changes to .htaccess. Expert mode serves a static file through Apache’s mod_rewrite and skips PHP entirely, and it is the fastest. The third, WP-Cache caching, is for visitors who are known by a cookie. Expert mode needs Apache with mod_rewrite and a change to .htaccess, and the FAQ warns that a mistake in that file can take the site down, so keep a copy before you edit it:
cp .htaccess .htaccess.bak-before-wpsc
What I measured first
With the plugin in simple mode, a cached article took 0.31 to 0.34 seconds at the origin. That is the time for PHP to start, WordPress to load far enough to find the cache file and, on my site, a connection to the Redis object cache, though I did not profile which part is slowest. These timings were taken on the server itself, sending curl to the local proxy so that Cloudflare and the visitor’s network are left out (--resolve points the domain at the server while keeping the real host name for the request):
curl -s -o /dev/null --resolve example.com:443:127.0.0.1 \
-w "%{http_code} %{time_starttransfer}s\n" \
-H "Accept-Encoding: gzip" https://example.com/some-post/
Time to first byte includes everything from the connection to the first byte, so it cannot say how much of the time was PHP and how much was Redis without profiling; I only compared the same request before and after the change.
200 0.338309s 200 0.314925s 200 0.323263s
The page’s own footer comment shows the generation time when the cache file is first built, for example Dynamic page generated in 0.283 seconds. After that, a static file should be quicker than that, and when I turned expert mode on at first it was not. The numbers did not change, which is the clue that the rewrite rules were not matching.
Problem 1: the cache was not being written at all
Before blaming the rewrite rules, confirm that cache files exist. On my site the first attempt created none, for three reasons:
- The
wp-content/cachefolder belonged to root, so the web server user (www-datain the official image) could not write to it. wp-content/advanced-cache.phpwas an empty file, 0 bytes, when the plugin needs it to contain its loader.wp-content/wp-cache-config.phpdid not exist. The plugin ships a sample file,wp-cache-config-sample.php, which it copies when it sets itself up.
Deactivating and reactivating the plugin should make it write both files again. I took a different route: I created the config from the plugin’s sample file and copied a working advanced-cache.php from a second site where the plugin was already set up. Then I fixed the ownership. The checks, and the ownership fix:
ls -la wp-content/advanced-cache.php wp-content/wp-cache-config.php
ls -ld wp-content/cache
chown -R www-data:www-data wp-content/cache wp-content/advanced-cache.php wp-content/wp-cache-config.php
Also make sure WP_CACHE is true in wp-config.php, which the plugin normally adds, and that the loader in advanced-cache.php points at the plugin folder that is really installed (it defines WPCACHEHOME). If you copy the file from another site, check that path, or prefer the loader that ships with your installed version. After the fix, open a few pages and look for files under wp-content/cache/supercache/your-domain/. Behind HTTPS the file is called index-https.html, not index.html, and that name matters later.
Problem 2: rules written from the command line have a doubled path
The easiest way to write the rules is the button in Settings, WP Super Cache, Advanced, after choosing the expert delivery method: Update Mod_Rewrite Rules. I generated them from a PHP script run in the container instead, and the result had this in every condition:
RewriteCond %{DOCUMENT_ROOT}/var/www/html/wp-content/cache/supercache/%{SERVER_NAME}/$1/index-https.html -f
On the command line, PHP has no DOCUMENT_ROOT, so the plugin could not strip the folder prefix from the cache path and left the full server path in. The correct form, relative to the web root, is this excerpt (the plugin’s full block also has the conditions that skip POST requests, query strings and visitors with logged-in, comment or password cookies, so keep those):
RewriteCond %{DOCUMENT_ROOT}/wp-content/cache/supercache/%{SERVER_NAME}/$1/index-https.html -f
RewriteRule ^(.*) "/wp-content/cache/supercache/%{SERVER_NAME}/$1/index-https.html" [L]
If you generate the rules from the dashboard instead, this problem should not appear, but still read the block before you rely on it.
Problem 3: Apache never sees HTTPS behind the proxy
This was the one that mattered. The plugin writes pairs of rules: one for HTTPS (looking for index-https.html) and one for plain HTTP (looking for index.html). It tells them apart with this line:
RewriteCond %{HTTPS} on
Behind a proxy that ends TLS, the request reaches Apache as plain HTTP, so %{HTTPS} is never on. The HTTPS rule never matches, and the HTTP rule looks for an index.html that WordPress never wrote, because from WordPress’s point of view (the wp-config.php block that reads the forwarded header) every request is HTTPS. Result: no rule matches, and every request falls through to PHP.
The proxy passes the original scheme in the X-Forwarded-Proto header, so test that instead. Inside the WPSuperCache block only, change the two conditions:
- RewriteCond %{HTTPS} on
+ RewriteCond %{HTTP:X-Forwarded-Proto} =https
- RewriteCond %{HTTPS} !on
+ RewriteCond %{HTTP:X-Forwarded-Proto} !=https
The plugin’s block appears four times in this pattern (gzip and plain, HTTPS and HTTP), so there are several lines to change, but they are all the same two substitutions. Edit only the lines between # BEGIN WPSuperCache and # END WPSuperCache, because other plugins, such as Wordfence, may use %{HTTPS} in their own blocks. The block must also come before the # BEGIN WordPress block, so that the cache rules get a chance before WordPress’s catch-all rewrite. Apache reads .htaccess on every request, so no reload is needed, and apache2ctl -t does not check it. A typo shows up as a 500 error on the next request, so test straight away with a real request and keep the backup copy handy.
A note on trust: the rule believes the X-Forwarded-Proto header, and so does the wp-config.php block that makes WordPress think the request is HTTPS. Only use this if the header is set or overwritten by a proxy you control, and if visitors cannot reach the Apache container directly and send their own value.
The result
After the change, the same request that took 0.32 seconds took about 0.02 seconds:
200 0.023688s 200 0.023227s 200 0.021702s
Pages that must stay dynamic were not served from the cache. In the rules my site generated, the conditions skip any POST, any URL with a query string, and visitors with a logged-in, comment-author or password cookie. In my settings I also excluded the contact page, sitemaps, XML files, the feed, the JSON API and XML-RPC with cache_rejected_uri, and on my site those still took about 0.2 to 0.33 seconds each, as they should. A visitor with a logged-in cookie also still got the dynamic page.
Where Cloudflare fits
By default, Cloudflare does not cache HTML pages, and my pages came back with cf-cache-status: DYNAMIC. That means each page view still reaches your origin, so a fast origin is what visitors feel. From the server, a request through Cloudflare took roughly 0.28 to 0.37 seconds for the first byte, which includes the extra hop and the TLS handshake. Making Cloudflare cache HTML is a separate decision with its own risks, such as serving a logged-in page to someone else, so do not combine the two without testing.
Keeping the change
- The plugin may rewrite its block. Saving settings or pressing the update button can regenerate the rules and undo your
X-Forwarded-Protoedit. After any change in the plugin’s settings, check the block again. - Clear the cache after layout changes. Use the plugin’s delete-cache button, or remove the files in
wp-content/cache/supercache/. - Rollback. Restore the saved
.htaccessand switch the delivery method back to simple. The cached files are only a cache.
Common questions
Why is the plugin’s own timing still 0.28 seconds in the page footer?
That comment records how long the page took to generate when the file was first built. It is not the time it takes to serve the static file.
Does this work on Nginx?
No. The rewrite rules are for Apache. The plugin’s FAQ says that Nginx needs extra steps, and simple mode is the usual choice there.
Can I keep the cache for an hour only?
The plugin has an expiry setting, and I used 3600 seconds. In expert mode the rewrite rules serve a static file without checking its age, so old files are only removed when the plugin’s scheduled clean-up runs, which needs WP-Cron or a system cron job to work. WP-Cron only runs when someone visits, so on a quiet site a system cron job is more dependable (see the WP-Cron guide). I also turned on the option that clears the cache when a post is published or updated; that clears cached files broadly, not only the one for that post.
