When Varnish and an application page cache store the same page, an edit needs to invalidate both copies. I trace that invalidation from WordPress to each layer, then test the next public request. Clearing only the inner cache can leave the outer one serving an older page.
I make the purge policy cover individual URLs, related listings and whole-site changes, with the same host boundary at every layer.
Who does what
| Layer | Serves | Good at |
|---|---|---|
| Varnish | Logged-out visitors and bots | Answering repeat visits in about 2 ms without touching PHP |
| Redis page cache | Whatever Varnish misses | Serving a fresh copy right after an edit, before the app fully loads |
| Redis object cache | Logged-in users, admin, background jobs | Saving database round trips on pages that cannot be cached whole |
Rule one: purge both, every time
Give Varnish a way to drop pages by host and path, and only accept that request from the machine itself:
acl purgers { "127.0.0.1"; "::1"; }
sub vcl_recv {
if (req.method == "BAN") {
if (client.ip !~ purgers) { return (synth(405, "Not allowed")); }
ban("obj.http.x-host == " + req.http.host + " && obj.http.x-url ~ " + req.http.x-ban-url);
return (synth(200, "Banned"));
}
}
sub vcl_backend_response {
# Remember where each object came from, so bans can match it later.
set beresp.http.x-host = bereq.http.host;
set beresp.http.x-url = bereq.url;
}
sub vcl_deliver {
unset resp.http.x-host;
unset resp.http.x-url;
}Then, wherever your application clears a page from Redis, send the same path to Varnish. In PHP that can be as small as this:
<?php
function varnish_ban_path(string $host, string $path): void
{
// Match the path with or without a trailing slash or query string.
$pattern = '^' . preg_quote(rtrim($path, '/'), '/') . '/?(\?.*)?$';
$ch = curl_init('http://127.0.0.1:6081/');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'BAN',
CURLOPT_HTTPHEADER => ['Host: ' . $host, 'X-Ban-Url: ' . $pattern],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 2,
]);
curl_exec($ch);
}A full clear should ban the whole host. Collect bans during a request and send them once at the end, so a busy save does not fire fifty requests.
Rule two: never store what is not yours to share
Most cache horror stories are one visitor seeing another visitor’s basket. Pass anything personal straight through, and refuse to store any response that sets a cookie or says it is private:
sub vcl_recv {
if (req.http.Cookie ~ "wordpress_logged_in_|wp-postpass_|comment_author_|woocommerce_|PHPSESSID") {
return (pass);
}
if (req.url ~ "/(wp-admin|wp-login\.php|cart|checkout|my-account)" || req.url ~ "rest_route=") {
return (pass);
}
}
sub vcl_backend_response {
if (beresp.http.Set-Cookie || beresp.http.Cache-Control ~ "private|no-store|no-cache") {
set beresp.uncacheable = true;
set beresp.ttl = 120s;
return (deliver);
}
}Rule three: mark stale, do not wipe
When a popular page changes, wiping it means the next hundred visitors all ask PHP for it at once. Serve the old copy for a few seconds while one request builds the new one. In Varnish that is grace:
sub vcl_backend_response {
set beresp.grace = 6h;
}Do the same in your application cache if it supports a soft purge. After an edit, visitors keep getting a fast page and only one rebuild happens.
Rule four: keep browsers out of it
Let the shared caches hold HTML and tell browsers not to. If a browser keeps a broken page for a week, no purge on your server can reach it.
Cache-Control: public, s-maxage=3600, max-age=0Check it is working
# Twice in a row: the second should be much faster, with an Age header
curl -sI https://example.com/ | grep -iE '^(age|x-cache|cache-control):'
curl -s -o /dev/null -w '%{time_starttransfer}s\n' https://example.com/
# Hit Varnish directly from the server
curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' -H 'Host: example.com' http://127.0.0.1:6081/Edit a page, load it, and make sure you see the change straight away. That one test tells you more than any benchmark.