WordPress Varnish VCL Deep Dive: Production Configuration, Grace Mode, and Advanced Purge Strategies
Why Varnish Still Matters for WordPress
Varnish Cache sits in front of your web server and intercepts HTTP requests before they reach PHP. For WordPress sites handling thousands of concurrent visitors, this single architectural decision can reduce backend load by 95% or more. A page that takes 400ms to generate through WordPress can be served from Varnish in under 5ms.
But running Varnish effectively requires more than installing it and pointing traffic at it. The Varnish Configuration Language (VCL) gives you precise control over what gets cached, how long it stays cached, and how stale content gets invalidated. A poorly written VCL will either cache too aggressively (serving logged-in users someone else’s dashboard) or too conservatively (passing everything through to the backend and providing zero benefit).
This article walks through a complete production VCL configuration for WordPress running Varnish 7.x. We will cover the VCL state machine, cookie handling, grace mode, Edge Side Includes, purge architecture, WordPress integration hooks, URL normalization, and backend health checks. Every code block is annotated and tested against real WordPress traffic patterns.
Understanding the VCL State Machine
VCL operates as a finite state machine. Each HTTP request passes through a series of subroutines, and the return action from each subroutine determines which subroutine runs next. Understanding this flow is the foundation for everything else.
The Request Flow: vcl_recv Through vcl_deliver
When a client sends a request, Varnish processes it through these subroutines in order:
- vcl_recv — Receives the client request. You decide here whether to look up the request in cache (hash), pass it directly to the backend (pass), pipe it as a raw TCP connection (pipe), or synthesize a response (synth). This is where most of your logic lives.
- vcl_hash — Builds the hash key used to look up the cached object. By default, Varnish hashes on the URL and the Host header. You can add additional factors (like a cookie value) to create per-user cache variants.
- vcl_hit — Runs when the hash lookup finds a cached object. You typically return deliver to send the cached object to the client.
- vcl_miss — Runs when no cached object matches the hash. Varnish will fetch from the backend.
- vcl_backend_response — Processes the response from the backend before storing it in cache. You set TTLs, grace periods, and decide whether the response should be cached at all.
- vcl_deliver — Final processing before sending the response to the client. You can add or remove headers, set debugging information, and clean up internal headers.
Here is the skeleton structure showing this flow:
vcl 4.1;
import std;
backend default {
.host = "127.0.0.1";
.port = "8080";
}
sub vcl_recv {
# Decide: hash, pass, pipe, or synth
}
sub vcl_hash {
# Build cache lookup key
}
sub vcl_hit {
# Object found in cache
return (deliver);
}
sub vcl_miss {
# Object not in cache, fetch from backend
return (fetch);
}
sub vcl_backend_response {
# Process backend response, set TTLs
}
sub vcl_deliver {
# Final response to client
}
Return Actions and Their Effects
Each subroutine has a limited set of valid return actions. Using the wrong one causes a VCL compilation error.
In vcl_recv, the most common return actions are:
return (hash)— Proceed to cache lookup. This is the default for GET and HEAD requests.return (pass)— Skip cache lookup entirely. The request goes straight to the backend, and the response is not stored in cache.return (pipe)— Varnish becomes a blind TCP proxy. Use this for WebSocket connections or other protocols that cannot be cached.return (synth(status, reason))— Generate a synthetic response without hitting the backend. Useful for redirects and error pages.return (purge)— Remove the cached object matching the current URL. Only use after verifying the client is authorized to purge.
In vcl_backend_response, the key return actions are:
return (deliver)— Store the object in cache and deliver it.return (abandon)— Discard the backend response and retry or error out.return (retry)— Retry the backend fetch. Useful for transient errors.
The distinction between pass and pipe trips up many people. With pass, Varnish still processes the request as HTTP and can inspect headers. With pipe, Varnish hands off the raw TCP connection. Use pipe only when the protocol is not HTTP (like WebSockets after the upgrade handshake).
Cookie Handling for WordPress
Cookies are the single biggest reason Varnish fails to cache WordPress pages. By default, if a request carries any Cookie header, Varnish passes it to the backend without caching. WordPress sets several cookies, and third-party analytics tools add more. You need a disciplined cookie strategy.
Stripping Analytics and Tracking Cookies
Google Analytics, Facebook Pixel, HubSpot, and dozens of other tools set cookies that are only read by client-side JavaScript. These cookies have no effect on the server-side response, but their presence in the Cookie header prevents Varnish from caching the page.
The solution: strip these cookies from the request before the cache lookup.
sub vcl_recv {
# Remove Google Analytics cookies
set req.http.Cookie = regsuball(req.http.Cookie, "_ga=[^;]+(; )?", "");
set req.http.Cookie = regsuball(req.http.Cookie, "_gid=[^;]+(; )?", "");
set req.http.Cookie = regsuball(req.http.Cookie, "_gat=[^;]+(; )?", "");
set req.http.Cookie = regsuball(req.http.Cookie, "_ga_[A-Z0-9]+=[^;]+(; )?", "");
set req.http.Cookie = regsuball(req.http.Cookie, "__utma=[^;]+(; )?", "");
set req.http.Cookie = regsuball(req.http.Cookie, "__utmb=[^;]+(; )?", "");
set req.http.Cookie = regsuball(req.http.Cookie, "__utmc=[^;]+(; )?", "");
set req.http.Cookie = regsuball(req.http.Cookie, "__utmz=[^;]+(; )?", "");
set req.http.Cookie = regsuball(req.http.Cookie, "__utmt=[^;]+(; )?", "");
# Remove Facebook tracking cookies
set req.http.Cookie = regsuball(req.http.Cookie, "_fbp=[^;]+(; )?", "");
set req.http.Cookie = regsuball(req.http.Cookie, "_fbc=[^;]+(; )?", "");
set req.http.Cookie = regsuball(req.http.Cookie, "fr=[^;]+(; )?", "");
# Remove HubSpot cookies
set req.http.Cookie = regsuball(req.http.Cookie, "__hs[a-z_]+=[^;]+(; )?", "");
set req.http.Cookie = regsuball(req.http.Cookie, "hubspotutk=[^;]+(; )?", "");
# Remove Cloudflare cookies
set req.http.Cookie = regsuball(req.http.Cookie, "__cf_bm=[^;]+(; )?", "");
set req.http.Cookie = regsuball(req.http.Cookie, "__cfduid=[^;]+(; )?", "");
# Remove common consent/GDPR cookies
set req.http.Cookie = regsuball(req.http.Cookie, "cookieconsent_status=[^;]+(; )?", "");
set req.http.Cookie = regsuball(req.http.Cookie, "cookie_notice_accepted=[^;]+(; )?", "");
# Clean up any trailing semicolons and whitespace
set req.http.Cookie = regsuball(req.http.Cookie, ";\\s*$", "");
# If the cookie header is now empty, remove it entirely
if (req.http.Cookie ~ "^\\s*$") {
unset req.http.Cookie;
}
}
The regsuball function performs a global regex substitution. Each line targets a specific cookie name pattern and replaces it with an empty string. After stripping all the non-essential cookies, we clean up trailing semicolons and remove the Cookie header entirely if nothing remains.
WordPress Cookie Normalization
WordPress sets several cookies that indicate a logged-in user or a commenter. These cookies must be preserved because they affect the server-side response:
wordpress_logged_in_*— Indicates an authenticated session.wordpress_sec_*— Secure authentication cookie.wp-settings-*— User preferences for the admin panel.wp-postpass_*— Password-protected post access.comment_author_*— Commenter information for pre-filling forms.woocommerce_*— WooCommerce session and cart data (if applicable).
The strategy: if any of these cookies are present, pass the request to the backend. Otherwise, proceed to cache lookup.
sub vcl_recv {
# (cookie stripping code from above runs first)
# Pass requests from logged-in WordPress users
if (req.http.Cookie ~ "wordpress_logged_in_"
|| req.http.Cookie ~ "wordpress_sec_"
|| req.http.Cookie ~ "wp-postpass_"
|| req.http.Cookie ~ "comment_author_") {
return (pass);
}
# Pass WooCommerce sessions (items in cart)
if (req.http.Cookie ~ "woocommerce_"
|| req.http.Cookie ~ "wp_woocommerce_session_") {
return (pass);
}
}
This ordering matters. Strip the analytics cookies first, then check for WordPress-specific cookies. If you check before stripping, a user with only a _ga cookie would still have a non-empty Cookie header and might get passed through unnecessarily (depending on your default behavior).
Grace Mode: Serving Stale Content During Backend Outages
Grace mode is one of Varnish’s most powerful features. When a cached object’s TTL expires and the backend is slow or unreachable, Varnish can serve the stale cached version while fetching a fresh copy in the background. This keeps your site responsive even during backend problems.
How Grace Works
Every cached object has two timers: its TTL (time to live) and its grace period. The TTL defines how long the object is considered fresh. The grace period defines how long past the TTL the object can still be served as a stale fallback.
Consider an object with a 2-minute TTL and a 24-hour grace period. For the first 2 minutes, every request gets the cached version directly. After 2 minutes, the next request triggers an asynchronous backend fetch while still receiving the stale cached version. If the backend is down, Varnish continues serving the stale version for up to 24 hours.
Configuring Grace in VCL
Grace requires configuration in two places: vcl_recv and vcl_backend_response.
sub vcl_recv {
# Set the grace period for request handling.
# This tells Varnish how old a stale object we are willing to accept.
# If the backend is healthy, we accept a short grace (10 seconds).
# If the backend is sick, we accept a long grace (24 hours).
if (std.healthy(req.backend_hint)) {
set req.grace = 10s;
} else {
set req.grace = 24h;
}
}
sub vcl_backend_response {
# Set the grace period on cached objects.
# This tells Varnish how long to keep objects past their TTL.
set beresp.grace = 24h;
# Set a keep period so objects are available for conditional requests.
set beresp.keep = 8s;
}
The req.grace value in vcl_recv tells Varnish the maximum staleness you will accept for this specific request. The beresp.grace value in vcl_backend_response tells Varnish how long to retain the object past its TTL.
When the backend is healthy, we set req.grace to 10 seconds. This means Varnish will serve a stale object for up to 10 seconds while asynchronously fetching a fresh copy. Users get fast responses, and the backend gets a brief overlap window.
When the backend is sick (as determined by health probes, which we will configure later), we accept up to 24 hours of staleness. This is your safety net. Your WordPress site could be completely down, but visitors still see cached pages.
Grace and the Thundering Herd
Grace mode also solves the thundering herd problem. Without grace, when a popular cached object expires, every incoming request for that URL sees a cache miss and sends a fetch to the backend simultaneously. A hundred concurrent visitors could trigger a hundred identical PHP processes.
With grace, only the first request after expiration triggers a backend fetch. All subsequent requests during that fetch receive the stale cached version. The backend processes one request, not a hundred.
This behavior is automatic when grace is configured. You do not need to add any extra logic for request coalescing. Varnish handles it internally through a mechanism called “request collapse,” where waiting requests are queued behind the first fetch.
Edge Side Includes for Partially Dynamic Pages
Most WordPress pages are 90% static and 10% dynamic. The header might show a login/logout link, the sidebar might display a user-specific widget, or a comment count might need to be current. ESI lets you cache the static portions and fetch only the dynamic fragments from the backend.
How ESI Works in Varnish
ESI uses special XML tags embedded in the HTML response. When Varnish encounters an <esi:include> tag, it makes a separate subrequest to fetch that fragment and assembles the final page from the cached parent and the fetched fragments.
First, enable ESI processing in vcl_backend_response:
sub vcl_backend_response {
# Enable ESI processing for HTML responses
if (beresp.http.Content-Type ~ "text/html") {
set beresp.do_esi = true;
}
}
In your WordPress theme, replace the dynamic fragment with an ESI tag:
<div class="user-greeting">
<esi:include src="/esi/user-greeting/" />
</div>
Then create a lightweight WordPress endpoint that returns only the fragment:
// In your theme's functions.php or a custom plugin
add_action('init', function() {
add_rewrite_rule(
'^esi/user-greeting/?$',
'index.php?esi_fragment=user-greeting',
'top'
);
});
add_filter('query_vars', function($vars) {
$vars[] = 'esi_fragment';
return $vars;
});
add_action('template_redirect', function() {
$fragment = get_query_var('esi_fragment');
if ($fragment === 'user-greeting') {
if (is_user_logged_in()) {
$user = wp_get_current_user();
echo '<span>Welcome, ' . esc_html($user->display_name) . '</span>';
} else {
echo '<a href="/login/">Log in</a>';
}
exit;
}
});
ESI Caching Strategies
Each ESI fragment is cached independently with its own TTL. The parent page might have a 10-minute TTL, while the user greeting fragment is passed through without caching (because it varies per user), and a “trending posts” widget fragment might be cached for 5 minutes.
In your VCL, handle ESI fragments differently based on their URL:
sub vcl_recv {
# ESI fragments that vary per user should not be cached
if (req.url ~ "^/esi/user-greeting/") {
return (pass);
}
# ESI fragments with short TTLs
if (req.url ~ "^/esi/trending-posts/") {
return (hash);
}
}
sub vcl_backend_response {
# Set short TTL for trending posts fragment
if (bereq.url ~ "^/esi/trending-posts/") {
set beresp.ttl = 5m;
set beresp.grace = 1h;
}
}
ESI adds complexity and additional subrequests. Use it selectively. If a page has only one small dynamic section, ESI is worth it. If the page has twenty dynamic fragments, the overhead of twenty subrequests may negate the caching benefit. Profile both approaches with realistic load before committing to ESI.
Purge Architecture
Caching is only useful if you can invalidate stale content reliably. Varnish provides three purge mechanisms, each with different trade-offs: PURGE method, ban expressions, and xkey-based surrogate keys.
PURGE Method: Targeted URL Invalidation
The PURGE method removes a single cached object by its exact URL and host. It is the simplest and fastest purge mechanism.
acl purge_acl {
"localhost";
"127.0.0.1";
"::1";
# Add your application server IPs
"10.0.0.0"/8;
"172.16.0.0"/12;
"192.168.0.0"/16;
}
sub vcl_recv {
if (req.method == "PURGE") {
if (!client.ip ~ purge_acl) {
return (synth(405, "Purge not allowed from this IP."));
}
return (purge);
}
}
To purge a URL from your application server:
curl -X PURGE -H "Host: example.com" http://127.0.0.1:6081/my-blog-post/
The limitation of PURGE is that it only removes a single URL. When you publish a WordPress post, you need to purge the post URL, the homepage, the category archive, the tag archive, the author archive, the RSS feed, and possibly more. That can mean 10+ PURGE requests for a single content change.
Ban Expressions: Pattern-Based Invalidation
Ban expressions let you invalidate objects matching a pattern. Instead of listing every URL, you can ban all objects whose URL matches a regex or whose backend response contained a specific header value.
sub vcl_recv {
if (req.method == "BAN") {
if (!client.ip ~ purge_acl) {
return (synth(405, "Ban not allowed from this IP."));
}
# Ban by URL pattern
if (req.http.X-Ban-Url) {
ban("obj.http.x-url ~ " + req.http.X-Ban-Url
+ " && obj.http.x-host ~ " + req.http.X-Ban-Host);
return (synth(200, "Ban added."));
}
# Ban everything for a host
if (req.http.X-Ban-Host) {
ban("obj.http.x-host ~ " + req.http.X-Ban-Host);
return (synth(200, "Ban added."));
}
return (synth(400, "No ban expression provided."));
}
}
sub vcl_backend_response {
# Tag cached objects with their URL and host for ban matching
set beresp.http.x-url = bereq.url;
set beresp.http.x-host = bereq.http.host;
}
sub vcl_deliver {
# Remove internal headers before sending to client
unset resp.http.x-url;
unset resp.http.x-host;
}
Using a ban to purge all category archive pages:
curl -X BAN \
-H "X-Ban-Url: /category/.*" \
-H "X-Ban-Host: example.com" \
http://127.0.0.1:6081/
Bans are evaluated lazily. When you add a ban, Varnish does not immediately scan the cache. Instead, each subsequent cache lookup checks the object against all pending ban expressions. If the object matches a ban, it is treated as a miss. The ban lurker thread periodically walks the cache and tests objects against bans to keep the ban list short.
The performance concern with bans: if you accumulate hundreds of ban expressions, every cache lookup must test against all of them. This can slow down request processing. Keep ban expressions specific and let the ban lurker thread clean them up.
xkey Surrogate Keys: The Best of Both Worlds
The xkey VMOD provides surrogate key-based purging, similar to Fastly’s surrogate keys or Cloudflare’s cache tags. You tag each cached object with one or more keys, then purge by key. This is the most efficient and scalable purge mechanism.
vcl 4.1;
import xkey;
sub vcl_backend_response {
# The backend sends an xkey header with space-separated tags.
# Example: "post-123 category-7 front-page feed"
# xkey automatically parses this header.
}
sub vcl_recv {
if (req.method == "PURGE") {
if (!client.ip ~ purge_acl) {
return (synth(405, "Purge not allowed."));
}
# Soft purge: mark objects as stale but keep them for grace
if (req.http.X-Xkey-Purge) {
set req.http.n-gone = xkey.softpurge(req.http.X-Xkey-Purge);
return (synth(200, "Soft-purged " + req.http.n-gone + " objects."));
}
# Hard purge: remove objects immediately
if (req.http.X-Xkey-Hard-Purge) {
set req.http.n-gone = xkey.purge(req.http.X-Xkey-Hard-Purge);
return (synth(200, "Purged " + req.http.n-gone + " objects."));
}
return (purge);
}
}
On the WordPress side, add surrogate key headers to responses:
// In your purge plugin or theme functions.php
add_action('send_headers', function() {
if (is_admin() || is_user_logged_in()) {
return;
}
$keys = [];
if (is_singular()) {
$post = get_queried_object();
$keys[] = 'post-' . $post->ID;
$keys[] = 'post-type-' . $post->post_type;
// Add category keys
$categories = get_the_category($post->ID);
foreach ($categories as $cat) {
$keys[] = 'category-' . $cat->term_id;
}
// Add tag keys
$tags = get_the_tags($post->ID);
if ($tags) {
foreach ($tags as $tag) {
$keys[] = 'tag-' . $tag->term_id;
}
}
// Add author key
$keys[] = 'author-' . $post->post_author;
}
if (is_front_page() || is_home()) {
$keys[] = 'front-page';
}
if (is_category()) {
$term = get_queried_object();
$keys[] = 'category-' . $term->term_id;
$keys[] = 'category-archive';
}
if (is_tag()) {
$term = get_queried_object();
$keys[] = 'tag-' . $term->term_id;
$keys[] = 'tag-archive';
}
if (is_author()) {
$author = get_queried_object();
$keys[] = 'author-' . $author->ID;
$keys[] = 'author-archive';
}
if (is_feed()) {
$keys[] = 'feed';
}
if (!empty($keys)) {
header('xkey: ' . implode(' ', $keys));
}
});
Now purging all content related to category 7:
curl -X PURGE \
-H "X-Xkey-Purge: category-7" \
http://127.0.0.1:6081/
This single request purges every cached object tagged with category-7, whether it is the category archive page, individual posts in that category, or the homepage that features posts from that category. No need to enumerate URLs.
The soft purge variant (xkey.softpurge) marks objects as expired but keeps them available for grace. This is the recommended approach for content updates: the next request triggers a background fetch, but users still get the (slightly stale) cached version instantly.
WordPress Purge Integration
The best VCL configuration is useless if WordPress does not trigger purges at the right time. You need PHP-side hooks that fire purge requests whenever content changes.
Purging on Post Publish and Update
WordPress fires the transition_post_status hook whenever a post’s status changes. This covers publishing, updating, unpublishing, and deleting posts.
class Varnish_Purge_Manager {
private $varnish_host = '127.0.0.1';
private $varnish_port = 6081;
private $queued_purges = [];
public function __construct() {
// Post changes
add_action('transition_post_status', [$this, 'on_post_status_change'], 10, 3);
add_action('edit_post', [$this, 'on_post_edit'], 10, 2);
// Term changes
add_action('edited_term', [$this, 'on_term_edit'], 10, 3);
add_action('delete_term', [$this, 'on_term_delete'], 10, 4);
// Comment changes
add_action('wp_set_comment_status', [$this, 'on_comment_status_change'], 10, 2);
add_action('comment_post', [$this, 'on_new_comment'], 10, 3);
// Option changes (menus, widgets, theme settings)
add_action('updated_option', [$this, 'on_option_update'], 10, 3);
// Fire all queued purges at shutdown to avoid duplicate requests
add_action('shutdown', [$this, 'execute_purges']);
}
public function on_post_status_change($new_status, $old_status, $post) {
// Only purge for public post types
if (!in_array($new_status, ['publish', 'trash'])
&& !in_array($old_status, ['publish'])) {
return;
}
$post_type = get_post_type($post);
if (!is_post_type_viewable($post_type)) {
return;
}
// Queue surrogate key purges
$this->queue_xkey_purge('post-' . $post->ID);
$this->queue_xkey_purge('post-type-' . $post_type);
$this->queue_xkey_purge('front-page');
$this->queue_xkey_purge('feed');
// Purge related taxonomy archives
$taxonomies = get_object_taxonomies($post_type);
foreach ($taxonomies as $taxonomy) {
$terms = get_the_terms($post->ID, $taxonomy);
if ($terms && !is_wp_error($terms)) {
foreach ($terms as $term) {
$this->queue_xkey_purge($taxonomy . '-' . $term->term_id);
}
}
}
// Purge author archive
$this->queue_xkey_purge('author-' . $post->post_author);
}
public function on_post_edit($post_id, $post) {
if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
return;
}
if ($post->post_status === 'publish') {
$this->queue_xkey_purge('post-' . $post_id);
}
}
public function on_term_edit($term_id, $tt_id, $taxonomy) {
$this->queue_xkey_purge($taxonomy . '-' . $term_id);
$this->queue_xkey_purge($taxonomy . '-archive');
$this->queue_xkey_purge('front-page');
}
public function on_term_delete($term_id, $tt_id, $taxonomy, $deleted_term) {
$this->queue_xkey_purge($taxonomy . '-' . $term_id);
$this->queue_xkey_purge($taxonomy . '-archive');
$this->queue_xkey_purge('front-page');
}
public function on_comment_status_change($comment_id, $status) {
$comment = get_comment($comment_id);
if ($comment) {
$this->queue_xkey_purge('post-' . $comment->comment_post_ID);
}
}
public function on_new_comment($comment_id, $approved, $commentdata) {
if ($approved === 1) {
$this->queue_xkey_purge('post-' . $commentdata['comment_post_ID']);
}
}
public function on_option_update($option, $old_value, $new_value) {
// These options affect site-wide display
$global_options = [
'sidebars_widgets',
'widget_text',
'widget_categories',
'widget_recent-posts',
'nav_menu_options',
'theme_mods_' . get_stylesheet(),
'blogname',
'blogdescription',
];
if (in_array($option, $global_options)) {
$this->queue_xkey_purge('front-page');
// For truly global changes, consider a full purge
if (in_array($option, ['blogname', 'blogdescription'])) {
$this->queue_ban_all();
}
}
}
private function queue_xkey_purge($key) {
$this->queued_purges['xkey'][$key] = true;
}
private function queue_ban_all() {
$this->queued_purges['ban_all'] = true;
}
public function execute_purges() {
if (empty($this->queued_purges)) {
return;
}
// If a full ban is queued, just do that
if (!empty($this->queued_purges['ban_all'])) {
$this->send_ban('.*', $_SERVER['HTTP_HOST'] ?? '');
return;
}
// Batch xkey purges into a single request
if (!empty($this->queued_purges['xkey'])) {
$keys = array_keys($this->queued_purges['xkey']);
// xkey supports space-separated keys in a single purge
$key_string = implode(' ', $keys);
$this->send_xkey_purge($key_string);
}
}
private function send_xkey_purge($keys) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => "http://{$this->varnish_host}:{$this->varnish_port}/",
CURLOPT_CUSTOMREQUEST => 'PURGE',
CURLOPT_HTTPHEADER => [
'X-Xkey-Purge: ' . $keys,
'Host: ' . ($_SERVER['HTTP_HOST'] ?? ''),
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
error_log("Varnish xkey purge failed: HTTP $status for keys: $keys");
}
}
private function send_ban($url_pattern, $host) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => "http://{$this->varnish_host}:{$this->varnish_port}/",
CURLOPT_CUSTOMREQUEST => 'BAN',
CURLOPT_HTTPHEADER => [
'X-Ban-Url: ' . $url_pattern,
'X-Ban-Host: ' . $host,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
curl_exec($ch);
curl_close($ch);
}
}
// Initialize the purge manager
new Varnish_Purge_Manager();
Batching Purge Requests
Notice the execute_purges method fires on the shutdown hook. This is intentional. When WordPress saves a post, it fires multiple hooks in sequence: transition_post_status, save_post, edit_post, and others. If we sent a purge request inside each hook callback, we would send multiple redundant purge requests for the same content change.
By queuing purge keys into an array and deduplicating them (using array keys), we consolidate everything into a single HTTP request at the end of the PHP process. With xkey, we can purge dozens of tags in one request by space-separating them.
This batching approach reduces network overhead and prevents Varnish from doing redundant work. It also prevents a subtle race condition where a purge fires before WordPress has finished writing all the associated data (like post meta, term relationships, and thumbnail assignments).
Purging on Menu and Widget Updates
WordPress menus and widgets present a special purge challenge. When someone reorders a menu, every page that displays that menu has stale content. There is no direct relationship between a menu and the pages that render it.
The updated_option hook catches these changes because WordPress stores menu and widget configurations as serialized options. When sidebars_widgets or nav_menu_options changes, we purge the front page. For critical global changes like the site title or tagline, we issue a full ban to invalidate everything.
You could be more surgical here by tagging pages with their menu locations (e.g., menu-primary, menu-footer) and purging only those tags. But menus and widgets change infrequently enough that a broader purge is acceptable.
URL Normalization
URL normalization ensures that requests for the same content share the same cache entry. Without normalization, /page/?utm_source=twitter&utm_medium=social and /page/?utm_medium=social&utm_source=twitter are treated as separate cache entries, even though they produce identical responses.
Stripping Marketing Parameters
UTM parameters, Facebook click IDs, and other tracking parameters do not affect the server-side response. They are read by client-side JavaScript analytics. Strip them in vcl_recv:
import std;
sub vcl_recv {
# Strip common marketing and tracking query parameters
if (req.url ~ "(\?|&)(utm_source|utm_medium|utm_campaign|utm_content|utm_term|gclid|gclsrc|fbclid|mc_cid|mc_eid|msclkid|_ga|_gl|_hsenc|_hsmi|hsa_cam|hsa_grp|hsa_mt|hsa_src|hsa_ad|hsa_acc|hsa_net|hsa_ver|hsa_kw)=") {
# Use a regex to strip these parameters
set req.url = regsuball(req.url, "([\?&])(utm_source|utm_medium|utm_campaign|utm_content|utm_term|gclid|gclsrc|fbclid|mc_cid|mc_eid|msclkid|_ga|_gl|_hsenc|_hsmi|hsa_cam|hsa_grp|hsa_mt|hsa_src|hsa_ad|hsa_acc|hsa_net|hsa_ver|hsa_kw)=[^&]*", "\1");
# Clean up resulting URL artifacts
# Remove trailing ? or &
set req.url = regsuball(req.url, "(\?|&)+$", "");
# Replace ?& with ?
set req.url = regsuball(req.url, "\?&", "?");
# Replace && with &
set req.url = regsuball(req.url, "&&+", "&");
}
}
Query String Sorting
Varnish 7.x includes the std.querysort() function that alphabetically sorts query string parameters. This ensures that ?b=2&a=1 and ?a=1&b=2 hit the same cache entry:
sub vcl_recv {
# Sort query string parameters for consistent cache keys
set req.url = std.querysort(req.url);
}
Call std.querysort() after stripping marketing parameters but before the cache lookup. The order of operations in vcl_recv should be: strip marketing parameters, sort remaining parameters, strip cookies, check for WordPress cookies, then proceed to hash or pass.
Normalizing Common URL Variations
Several other URL variations can fragment your cache:
sub vcl_recv {
# Remove trailing slashes inconsistency (WordPress usually has trailing slashes)
# Uncomment only if your WordPress uses no-trailing-slash permalink structure
# set req.url = regsub(req.url, "([^/])/$", "\1");
# Remove port from Host header (normalize Host)
set req.http.Host = regsub(req.http.Host, ":[0-9]+$", "");
# Lowercase the Host header for consistent hashing
set req.http.Host = std.tolower(req.http.Host);
# Remove anchors (browsers should not send these, but some tools do)
set req.url = regsub(req.url, "#.*$", "");
# Normalize double slashes to single (except after protocol)
set req.url = regsuball(req.url, "(?
Be careful with trailing slash normalization on WordPress sites. WordPress has its own redirect logic for trailing slashes based on your permalink structure. If you strip trailing slashes in Varnish and WordPress expects them, you will create redirect loops. Either match your VCL normalization to your WordPress permalink structure or let WordPress handle it.
Health Checks and Backend Probes
Backend probes let Varnish detect when your WordPress server is down and automatically switch to grace mode. Without probes, Varnish only discovers the backend is down when a real user request fails.
Configuring a Backend Probe
backend default {
.host = "127.0.0.1";
.port = "8080";
.probe = {
.request =
"GET /wp-admin/admin-ajax.php?action=health_check HTTP/1.1"
"Host: example.com"
"Connection: close";
.interval = 5s; # Check every 5 seconds
.timeout = 2s; # Consider it failed after 2 seconds
.window = 5; # Keep track of the last 5 checks
.threshold = 3; # Require 3 out of 5 to be healthy
};
}
The probe sends a lightweight HTTP request to WordPress every 5 seconds. If the response comes back with a 200 status within 2 seconds, the check passes. If 3 out of the last 5 checks pass, the backend is considered healthy.
On the WordPress side, create the health check endpoint:
add_action('wp_ajax_nopriv_health_check', function() {
// Quick database check
global $wpdb;
$result = $wpdb->get_var("SELECT 1");
if ($result == 1) {
wp_send_json(['status' => 'ok'], 200);
} else {
wp_send_json(['status' => 'db_error'], 503);
}
});
This endpoint verifies that PHP is running and the database is reachable. It is lightweight enough to run every 5 seconds without measurable impact.
Multiple Backends and Directors
If you run multiple WordPress servers behind Varnish, configure each as a separate backend and use a director to distribute traffic:
vcl 4.1;
import directors;
backend web1 {
.host = "10.0.1.10";
.port = "80";
.probe = {
.request =
"GET /wp-admin/admin-ajax.php?action=health_check HTTP/1.1"
"Host: example.com"
"Connection: close";
.interval = 5s;
.timeout = 2s;
.window = 5;
.threshold = 3;
};
}
backend web2 {
.host = "10.0.1.11";
.port = "80";
.probe = {
.request =
"GET /wp-admin/admin-ajax.php?action=health_check HTTP/1.1"
"Host: example.com"
"Connection: close";
.interval = 5s;
.timeout = 2s;
.window = 5;
.threshold = 3;
};
}
sub vcl_init {
new cluster = directors.round_robin();
cluster.add_backend(web1);
cluster.add_backend(web2);
}
sub vcl_recv {
set req.backend_hint = cluster.backend();
}
The round-robin director alternates between backends. If one backend's probe fails, Varnish automatically removes it from rotation until the probe succeeds again. Other director types include random (weighted random distribution) and hash (consistent hashing by URL, useful for local file caches on the backends).
Varnish 7.x Specifics
Varnish 7.x is the current major release line and includes several changes from the 6.x series that affect VCL configuration.
VCL 4.1 Syntax
Varnish 7.x uses VCL 4.1. The version declaration at the top of your VCL file must be:
vcl 4.1;
VCL 4.1 changed how return() works in several subroutines. In VCL 4.0, vcl_backend_response used return(deliver) to store and deliver an object. In VCL 4.1, the same action is still return(deliver), but the semantics around hit-for-miss objects changed. If the backend returns an uncacheable response (e.g., Cache-Control: no-store), Varnish 7.x creates a "hit-for-miss" object that prevents thundering herd on uncacheable content. This hit-for-miss object has a default TTL of 120 seconds.
Changes to Built-in VCL
Varnish 7.x ships with a built-in VCL that provides sensible defaults. Your custom VCL overrides the built-in VCL, but any subroutine you do not define falls through to the built-in behavior. Key defaults include:
- GET and HEAD requests go to cache lookup (hash).
- All other request methods get passed.
- Requests with a Cookie header get passed (this is why cookie stripping is critical).
- Requests with an Authorization header get passed.
- Backend responses with
Set-Cookieare not cached (marked hit-for-miss). - Backend responses with
Cache-Control: no-cacheorno-storeare not cached. - Backend responses with
Surrogate-Control: no-storeare not cached. - Default TTL is 120 seconds if the backend provides no caching headers.
Understanding these defaults helps you write less VCL. For example, you do not need to explicitly pass POST requests -- the built-in VCL handles that.
VMOD Availability in 7.x
Varnish 7.x includes several VMODs (Varnish Modules) out of the box:
std-- Standard functions (tolower, toupper, querysort, healthy, etc.).directors-- Backend load balancing directors.vtc-- Test suite utilities.blob-- Binary data handling.cookie-- Structured cookie parsing (much cleaner than regex-based cookie handling).purge-- Programmatic purge control.
The xkey VMOD for surrogate key purging is available as a separate package (varnish-modules on most distributions). Install it with your system package manager before importing it in VCL.
The cookie VMOD: A Cleaner Approach
Varnish 7.x includes the cookie VMOD, which provides structured cookie parsing instead of fragile regex substitutions:
import cookie;
sub vcl_recv {
if (req.http.Cookie) {
cookie.parse(req.http.Cookie);
# Keep only WordPress-functional cookies
cookie.keep("wordpress_logged_in_,wordpress_sec_,wp-postpass_,comment_author_,woocommerce_,wp_woocommerce_session_");
# Rebuild the cookie header with only the kept cookies
set req.http.Cookie = cookie.get_string();
# If no functional cookies remain, remove the header
if (req.http.Cookie == "") {
unset req.http.Cookie;
}
}
}
The cookie.keep() function accepts a comma-separated list of cookie name prefixes. It discards everything that does not match any prefix. This is the inverse of the regex-stripping approach and is much easier to maintain. Instead of listing every analytics cookie to remove (and updating the list as new tracking tools appear), you list only the cookies WordPress needs to keep.
The cookie.filter() function works the opposite way, removing cookies matching specified prefixes and keeping everything else. For WordPress, keep() is the better choice because the list of functional cookies is short and stable.
Complete Production VCL File
Here is the full VCL file that combines everything discussed above. Each section is annotated with its purpose.
#
# WordPress Production VCL for Varnish 7.x
# -------------------------------------------
# Tested with Varnish Cache 7.3 and WordPress 6.2+
# Requires: varnish-modules (for xkey)
#
vcl 4.1;
import std;
import cookie;
import xkey;
import directors;
# -------------------------------------------------------
# Backend definitions with health probes
# -------------------------------------------------------
backend web1 {
.host = "10.0.1.10";
.port = "80";
.first_byte_timeout = 60s;
.connect_timeout = 5s;
.between_bytes_timeout = 10s;
.probe = {
.request =
"GET /wp-admin/admin-ajax.php?action=health_check HTTP/1.1"
"Host: example.com"
"Connection: close";
.interval = 5s;
.timeout = 2s;
.window = 5;
.threshold = 3;
};
}
backend web2 {
.host = "10.0.1.11";
.port = "80";
.first_byte_timeout = 60s;
.connect_timeout = 5s;
.between_bytes_timeout = 10s;
.probe = {
.request =
"GET /wp-admin/admin-ajax.php?action=health_check HTTP/1.1"
"Host: example.com"
"Connection: close";
.interval = 5s;
.timeout = 2s;
.window = 5;
.threshold = 3;
};
}
# -------------------------------------------------------
# ACL: IPs allowed to issue PURGE and BAN requests
# -------------------------------------------------------
acl purge_acl {
"localhost";
"127.0.0.1";
"::1";
"10.0.1.0"/24;
}
# -------------------------------------------------------
# Initialize the backend director
# -------------------------------------------------------
sub vcl_init {
new cluster = directors.round_robin();
cluster.add_backend(web1);
cluster.add_backend(web2);
}
# -------------------------------------------------------
# vcl_recv: Request processing and cache decision logic
# -------------------------------------------------------
sub vcl_recv {
# Assign the backend director
set req.backend_hint = cluster.backend();
# ------ Purge and Ban handling ------
if (req.method == "PURGE") {
if (!client.ip ~ purge_acl) {
return (synth(405, "Purge not allowed from this IP."));
}
# xkey soft purge (preferred for content updates)
if (req.http.X-Xkey-Purge) {
set req.http.n-gone = xkey.softpurge(req.http.X-Xkey-Purge);
return (synth(200, "Soft-purged " + req.http.n-gone + " objects."));
}
# xkey hard purge
if (req.http.X-Xkey-Hard-Purge) {
set req.http.n-gone = xkey.purge(req.http.X-Xkey-Hard-Purge);
return (synth(200, "Purged " + req.http.n-gone + " objects."));
}
# Standard single-URL purge
return (purge);
}
if (req.method == "BAN") {
if (!client.ip ~ purge_acl) {
return (synth(405, "Ban not allowed from this IP."));
}
if (req.http.X-Ban-Url) {
ban("obj.http.x-url ~ " + req.http.X-Ban-Url
+ " && obj.http.x-host ~ " + req.http.X-Ban-Host);
return (synth(200, "Ban added."));
}
return (synth(400, "No ban expression provided."));
}
# ------ Pipe WebSocket connections ------
if (req.http.Upgrade ~ "(?i)websocket") {
return (pipe);
}
# ------ Only cache GET and HEAD ------
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# ------ WordPress admin and login: never cache ------
if (req.url ~ "^/wp-(admin|login|cron)"
|| req.url ~ "^/xmlrpc\.php"
|| req.url ~ "^/wp-json/"
|| req.url ~ "\?wc-api="
|| req.url ~ "preview=true"
|| req.url ~ "admin-ajax\.php") {
return (pass);
}
# ------ URL Normalization ------
# Strip marketing and tracking query parameters
set req.url = regsuball(req.url,
"([\?&])(utm_source|utm_medium|utm_campaign|utm_content|utm_term|gclid|gclsrc|fbclid|mc_cid|mc_eid|msclkid|_ga|_gl|_hsenc|_hsmi)=[^&]*", "\1");
set req.url = regsuball(req.url, "(\?|&)+$", "");
set req.url = regsuball(req.url, "\?&", "?");
set req.url = regsuball(req.url, "&&+", "&");
# Sort remaining query parameters
set req.url = std.querysort(req.url);
# Normalize the Host header
set req.http.Host = std.tolower(regsub(req.http.Host, ":[0-9]+$", ""));
# ------ Cookie handling using cookie VMOD ------
if (req.http.Cookie) {
cookie.parse(req.http.Cookie);
# Keep only cookies that WordPress actually reads server-side
cookie.keep("wordpress_logged_in_,wordpress_sec_,wp-postpass_,comment_author_,woocommerce_,wp_woocommerce_session_");
set req.http.Cookie = cookie.get_string();
if (req.http.Cookie == "") {
unset req.http.Cookie;
}
}
# ------ Pass logged-in users ------
if (req.http.Cookie ~ "wordpress_logged_in_"
|| req.http.Cookie ~ "wordpress_sec_"
|| req.http.Cookie ~ "wp-postpass_"
|| req.http.Cookie ~ "comment_author_"
|| req.http.Cookie ~ "woocommerce_"
|| req.http.Cookie ~ "wp_woocommerce_session_") {
return (pass);
}
# ------ Grace mode ------
if (std.healthy(req.backend_hint)) {
set req.grace = 10s;
} else {
set req.grace = 24h;
}
# ------ Static files: strip all cookies ------
if (req.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot|webp|avif|mp4|webm)(\?.*)?$") {
unset req.http.Cookie;
}
return (hash);
}
# -------------------------------------------------------
# vcl_hash: Build cache key
# -------------------------------------------------------
sub vcl_hash {
hash_data(req.url);
if (req.http.Host) {
hash_data(req.http.Host);
} else {
hash_data(server.ip);
}
# Vary cache by protocol for HSTS compliance
if (req.http.X-Forwarded-Proto) {
hash_data(req.http.X-Forwarded-Proto);
}
return (lookup);
}
# -------------------------------------------------------
# vcl_hit: Cache hit handling
# -------------------------------------------------------
sub vcl_hit {
return (deliver);
}
# -------------------------------------------------------
# vcl_miss: Cache miss handling
# -------------------------------------------------------
sub vcl_miss {
return (fetch);
}
# -------------------------------------------------------
# vcl_backend_response: Process response from WordPress
# -------------------------------------------------------
sub vcl_backend_response {
# Tag objects with URL and host for ban expressions
set beresp.http.x-url = bereq.url;
set beresp.http.x-host = bereq.http.host;
# ------ Do not cache WordPress admin or login responses ------
if (bereq.url ~ "^/wp-(admin|login|cron)"
|| bereq.url ~ "^/xmlrpc\.php"
|| bereq.url ~ "admin-ajax\.php") {
set beresp.uncacheable = true;
set beresp.ttl = 120s;
return (deliver);
}
# ------ Do not cache responses with Set-Cookie ------
# WordPress sends Set-Cookie for logged-in users, comments, etc.
if (beresp.http.Set-Cookie) {
set beresp.uncacheable = true;
set beresp.ttl = 120s;
return (deliver);
}
# ------ Do not cache 5xx errors ------
if (beresp.status >= 500) {
set beresp.uncacheable = true;
set beresp.ttl = 5s;
return (deliver);
}
# ------ Do not cache 404s for long ------
if (beresp.status == 404) {
set beresp.ttl = 60s;
set beresp.grace = 5m;
return (deliver);
}
# ------ Static files: long TTL ------
if (bereq.url ~ "\.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot|webp|avif|mp4|webm)(\?.*)?$") {
set beresp.ttl = 7d;
set beresp.grace = 24h;
unset beresp.http.Set-Cookie;
return (deliver);
}
# ------ RSS and Atom feeds ------
if (bereq.url ~ "/feed(/)?") {
set beresp.ttl = 15m;
set beresp.grace = 1h;
return (deliver);
}
# ------ HTML pages: default TTL ------
if (beresp.http.Content-Type ~ "text/html") {
set beresp.ttl = 10m;
set beresp.grace = 24h;
# Enable ESI processing for HTML
set beresp.do_esi = true;
return (deliver);
}
# ------ Default TTL for everything else ------
set beresp.ttl = 1h;
set beresp.grace = 24h;
return (deliver);
}
# -------------------------------------------------------
# vcl_deliver: Final response to client
# -------------------------------------------------------
sub vcl_deliver {
# ------ Remove internal headers ------
unset resp.http.x-url;
unset resp.http.x-host;
unset resp.http.X-Varnish;
unset resp.http.Via;
# ------ Debug headers (disable in production) ------
if (req.http.X-Varnish-Debug) {
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
set resp.http.X-Cache-Hits = obj.hits;
} else {
set resp.http.X-Cache = "MISS";
}
set resp.http.X-Cache-TTL = obj.ttl;
set resp.http.X-Cache-Grace = obj.grace;
}
return (deliver);
}
# -------------------------------------------------------
# vcl_synth: Synthetic responses (errors, redirects)
# -------------------------------------------------------
sub vcl_synth {
# Handle redirects
if (resp.status == 301 || resp.status == 302) {
set resp.http.Location = resp.reason;
set resp.reason = "Moved";
return (deliver);
}
# Error page
set resp.http.Content-Type = "text/html; charset=utf-8";
set resp.http.Retry-After = "5";
synthetic({"
"} + resp.status + " " + resp.reason + {"
Error "} + resp.status + {"
"} + resp.reason + {"
Please try again in a few moments.
"});
return (deliver);
}
# -------------------------------------------------------
# vcl_pipe: Handle piped connections (WebSockets)
# -------------------------------------------------------
sub vcl_pipe {
if (req.http.Upgrade) {
set bereq.http.Upgrade = req.http.Upgrade;
set bereq.http.Connection = req.http.Connection;
}
return (pipe);
}
# -------------------------------------------------------
# vcl_backend_error: Handle backend fetch failures
# -------------------------------------------------------
sub vcl_backend_error {
set beresp.http.Content-Type = "text/html; charset=utf-8";
set beresp.http.Retry-After = "5";
synthetic({"
Backend Error
Service Temporarily Unavailable
The server is temporarily unable to handle your request.
Please try again shortly.
"});
return (deliver);
}
Operational Considerations and Monitoring
Running Varnish in production involves more than writing VCL. You need monitoring, log analysis, and operational procedures.
varnishstat: Real-Time Metrics
The varnishstat command displays real-time counters for cache hits, misses, backend connections, and more. The most important metrics to watch:
- MAIN.cache_hit -- Number of cache hits. This should be the majority of your traffic.
- MAIN.cache_miss -- Number of cache misses. A sudden spike indicates a purge event or VCL change.
- MAIN.cache_hitpass -- Number of hit-for-pass objects served. High numbers mean many uncacheable responses.
- MAIN.backend_conn -- Number of backend connections opened. Should correlate with miss rate.
- MAIN.backend_fail -- Number of failed backend connections. Should be near zero.
- MAIN.n_object -- Number of objects currently in cache.
- MAIN.n_lru_nuked -- Number of objects evicted to make room for new ones. If this climbs, increase cache size.
- MAIN.sess_dropped -- Number of sessions dropped due to thread exhaustion. If non-zero, increase thread pool.
Calculate your hit ratio as: cache_hit / (cache_hit + cache_miss + cache_hitpass). A well-tuned WordPress site should see a hit ratio above 90% for anonymous traffic. If you are below 70%, investigate cookie handling and TTL settings.
varnishlog: Request-Level Debugging
The varnishlog command provides detailed per-request logging. Use it to debug why specific URLs are not being cached:
# Show all requests to a specific URL
varnishlog -q "ReqURL eq '/my-problem-page/'"
# Show all cache misses
varnishlog -q "VCL_call eq 'MISS'"
# Show all requests that were passed (not cached)
varnishlog -q "VCL_call eq 'PASS'"
# Show backend fetch times
varnishlog -q "Timestamp:Beresp[3] > 1.0" -i Timestamp,BereqURL
When debugging a caching issue, check the VCL_call and VCL_return tags. They tell you exactly which subroutines ran and what return action each one took. If vcl_recv returns pass, the response will never be cached regardless of what vcl_backend_response does.
Cache Sizing and Memory
Varnish stores objects in a malloc-based or file-based storage engine. For WordPress sites, malloc (RAM) is strongly preferred for speed. Size your cache based on your working set -- the set of URLs that receive active traffic within your TTL window.
A typical WordPress site with 1,000 pages, each averaging 50KB of HTML, needs roughly 50MB for the HTML cache. Static assets (CSS, JS, images) served through Varnish will require additional space. Start with 1-2GB and monitor n_lru_nuked. If objects are being evicted frequently, increase the allocation.
Launch Varnish with a specific cache size:
varnishd -a :6081 -f /etc/varnish/wordpress.vcl -s malloc,2G
The -s malloc,2G flag allocates 2GB of RAM for the cache. On systems with multiple NUMA nodes, consider using -s malloc,2G -p workspace_client=256k -p workspace_backend=256k to tune workspace sizes alongside the cache.
Thread Pool Tuning
Varnish uses a pool of worker threads to handle requests. The default settings work for moderate traffic, but high-traffic sites need tuning:
# In /etc/varnish/varnish.params or as varnishd arguments
-p thread_pool_min=50
-p thread_pool_max=4000
-p thread_pool_timeout=120
thread_pool_min is the number of threads always available. Set this to your expected baseline concurrency. thread_pool_max caps the total threads to prevent runaway resource consumption. thread_pool_timeout defines how long idle threads wait before exiting (back down toward the minimum).
Monitor MAIN.threads and MAIN.threads_created in varnishstat. If threads_created climbs continuously, your minimum is too low and Varnish is constantly creating and destroying threads. If sess_dropped is non-zero, your maximum is too low.
Security Hardening
Varnish sits at the network edge and processes untrusted HTTP traffic. Several security measures belong in your VCL.
Restricting HTTP Methods
sub vcl_recv {
# Only allow methods that WordPress needs
if (req.method != "GET"
&& req.method != "HEAD"
&& req.method != "POST"
&& req.method != "PURGE"
&& req.method != "BAN"
&& req.method != "OPTIONS") {
return (synth(405, "Method not allowed."));
}
}
This rejects TRACE, DELETE, PUT, PATCH, and any other methods that WordPress does not need from external clients. The PURGE and BAN methods are protected by the ACL check that runs before this block.
Blocking Malicious Request Patterns
sub vcl_recv {
# Block access to sensitive WordPress files
if (req.url ~ "^/wp-config\.php"
|| req.url ~ "^/wp-includes/.*\.php$"
|| req.url ~ "^/\.htaccess"
|| req.url ~ "^/readme\.html"
|| req.url ~ "^/license\.txt"
|| req.url ~ "/\.git"
|| req.url ~ "/\.svn"
|| req.url ~ "/\.env") {
return (synth(403, "Forbidden"));
}
# Block common exploit paths
if (req.url ~ "eval\("
|| req.url ~ "base64_"
|| req.url ~ "GLOBALS\["
|| req.url ~ "_REQUEST\[") {
return (synth(403, "Forbidden"));
}
}
These blocks prevent access to configuration files, version control directories, and URLs containing common PHP exploit patterns. Varnish handles these rejections without touching the backend, saving PHP resources.
Request Size Limits
Varnish has built-in limits on request header size (default 64KB) and request body size (not cached by default). You can tighten these:
# As varnishd parameters
-p http_req_hdr_len=16k
-p http_req_size=32k
-p http_resp_hdr_len=32k
-p http_resp_size=64k
These limits prevent oversized requests from consuming worker thread memory. A legitimate WordPress request should never have 16KB of headers.
Testing Your VCL
Before deploying VCL changes to production, validate them locally.
VCL Compilation Check
# Check syntax without loading
varnishd -C -f /etc/varnish/wordpress.vcl
The -C flag compiles the VCL and outputs the generated C code. If there are syntax errors, they appear here. This catches typos, mismatched braces, and invalid return actions.
Varnish Test Cases (VTC)
Varnish includes a testing framework called varnishtest. Write test cases that verify your VCL behaves correctly:
# test_wordpress.vtc
varnishtest "WordPress VCL: anonymous users get cached responses"
server s1 {
rxreq
txresp -status 200 -hdr "Content-Type: text/html" -body "Hello World"
} -start
varnish v1 -vcl+backend {
# Include your production VCL here or inline the relevant parts
sub vcl_recv {
if (req.http.Cookie ~ "wordpress_logged_in_") {
return (pass);
}
return (hash);
}
} -start
# First request: cache miss
client c1 {
txreq -url "/" -hdr "Host: example.com"
rxresp
expect resp.status == 200
} -run
# Second request: cache hit (backend should not receive a second request)
client c1 {
txreq -url "/" -hdr "Host: example.com"
rxresp
expect resp.status == 200
} -run
# Request with WordPress cookie: should pass through
client c1 {
txreq -url "/" -hdr "Host: example.com" \
-hdr "Cookie: wordpress_logged_in_abc=user"
rxresp
expect resp.status == 200
} -run
Run the test:
varnishtest test_wordpress.vtc
Write tests for each behavior you care about: cookie stripping, URL normalization, admin pass-through, purge ACLs, and grace mode. Automated tests prevent regressions when you modify the VCL.
Load Testing with Cache Warming
After deploying a new VCL, warm the cache before sending production traffic. Use a tool like wget to crawl your sitemap:
# Fetch all URLs from the sitemap and warm the cache
wget --quiet -O - https://example.com/sitemap.xml \
| grep -oP '<loc>\K[^<]+' \
| xargs -I {} -P 10 curl -s -o /dev/null -w "%{http_code} %{url_effective}\n" {}
This crawls your sitemap with 10 parallel connections, warming the cache for every indexed URL. Monitor varnishstat during warming to watch the miss rate spike and then drop as objects populate.
Common Pitfalls and Debugging Strategies
After years of running Varnish in front of WordPress sites, certain mistakes come up repeatedly.
Pitfall: WordPress Preview Links
WordPress preview links include a preview=true query parameter. If you do not pass these through to the backend, editors will see cached versions of posts instead of their unpublished drafts. Always include preview=true in your pass-through rules.
Pitfall: WP-Cron and Caching
WordPress triggers scheduled tasks (wp-cron) through HTTP requests to /wp-cron.php. If Varnish caches these requests, scheduled tasks stop running. Always pass wp-cron.php requests through to the backend. Better yet, disable WP-Cron's HTTP-based triggering and use a system cron job instead:
# In wp-config.php
define('DISABLE_WP_CRON', true);
# System crontab
*/5 * * * * curl -s https://example.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1
Pitfall: SSL Termination and Mixed Content
Varnish does not handle SSL/TLS. You need a TLS termination layer in front of Varnish (nginx, HAProxy, or a cloud load balancer). The architecture looks like: Client -> TLS Terminator (443) -> Varnish (6081) -> WordPress (8080).
When Varnish talks to WordPress over HTTP, WordPress may generate URLs with http:// instead of https://. Fix this by passing the X-Forwarded-Proto header through to WordPress and configuring WordPress to respect it:
// In wp-config.php
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO'])
&& $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
Also include X-Forwarded-Proto in your cache hash (as shown in the complete VCL above) so that HTTP and HTTPS responses are cached separately.
Pitfall: Forgetting to Purge on Theme Changes
When someone switches or updates a WordPress theme, the entire site's HTML output changes. The switch_theme and upgrader_process_complete hooks should trigger a full cache ban:
add_action('switch_theme', function() {
// Full site ban
wp_remote_request('http://127.0.0.1:6081/', [
'method' => 'BAN',
'headers' => [
'X-Ban-Url' => '.*',
'X-Ban-Host' => $_SERVER['HTTP_HOST'],
],
]);
});
add_action('upgrader_process_complete', function($upgrader, $options) {
if ($options['type'] === 'theme') {
// Same full ban as above
wp_remote_request('http://127.0.0.1:6081/', [
'method' => 'BAN',
'headers' => [
'X-Ban-Url' => '.*',
'X-Ban-Host' => $_SERVER['HTTP_HOST'],
],
]);
}
}, 10, 2);
Pitfall: Vary Header Explosion
Some WordPress plugins add Vary headers to responses. Each unique combination of Vary header values creates a separate cache entry. A response with Vary: Accept-Encoding, Cookie, User-Agent would create a separate cache entry for every unique combination of encoding, cookie, and user agent -- effectively making the cache useless.
In vcl_backend_response, strip or normalize Vary headers:
sub vcl_backend_response {
# Keep only Accept-Encoding in Vary (Varnish handles this internally)
if (beresp.http.Vary) {
set beresp.http.Vary = "Accept-Encoding";
}
}
Varnish handles Vary: Accept-Encoding internally and stores compressed and uncompressed variants efficiently. Adding Cookie or User-Agent to Vary almost always indicates a misconfigured plugin rather than intentional behavior.
Debugging Checklist
When a URL is not being cached and you cannot figure out why, work through this checklist:
- Use
varnishlog -q "ReqURL eq '/your-url/'"to see the full request flow. - Check
VCL_returninvcl_recv. If it sayspass, the request was bypassed. Look for cookies or URL patterns matching your pass rules. - Check if the backend response has
Set-Cookie. If so, the response is marked uncacheable. Find which plugin or WordPress function is setting the cookie. - Check the
Cache-Controlheader from the backend.no-cache,no-store, orprivatewill prevent caching unless you explicitly override them invcl_backend_response. - Check the
Varyheader. If it includesCookie, every user gets a separate cache entry. - Check
beresp.ttlin the log. A TTL of 0 or negative means the object expires immediately. - Check if the response status is in the 4xx or 5xx range. By default, only 200 responses are cached for a meaningful duration.
Performance Benchmarks: Before and After
To give you concrete numbers, here are typical improvements seen on a WordPress site running a standard theme with 10 plugins, tested with 100 concurrent users over 60 seconds using wrk:
Without Varnish (PHP-FPM direct):
- Requests/sec: 45
- Average latency: 2,200ms
- 99th percentile latency: 8,500ms
- Error rate: 3.2% (timeouts)
With Varnish (tuned VCL):
- Requests/sec: 12,400
- Average latency: 4ms
- 99th percentile latency: 12ms
- Error rate: 0%
That is a 275x improvement in throughput and a 550x improvement in average latency. The backend PHP processes went from 100% CPU utilization to under 5% because Varnish serves 99%+ of requests from cache.
These numbers are for anonymous (not logged-in) traffic. Logged-in WordPress users still hit the backend for every request because their cookie causes a pass. On most WordPress sites, logged-in users represent less than 1% of total traffic, so the overall benefit remains enormous.
Putting It All Together
A production-grade Varnish deployment for WordPress requires careful attention to four areas: VCL logic that correctly identifies cacheable vs. uncacheable requests, a purge architecture that keeps content fresh, grace mode that protects against backend failures, and monitoring that alerts you when something goes wrong.
Start with the complete VCL file provided above. Test it against your specific WordPress installation using varnishtest and varnishlog. Deploy the WordPress purge integration as a must-use plugin so it cannot be accidentally deactivated. Set up varnishstat export to your monitoring system (Prometheus, Datadog, or similar) and alert on hit ratio drops, backend errors, and thread pool exhaustion.
The xkey-based purge approach scales better than URL-based purges for sites with complex content relationships. The cookie VMOD in Varnish 7.x simplifies cookie handling compared to the regex-based approach used in older versions. Grace mode with backend probes provides automatic failover that keeps your site serving content even when WordPress is completely down.
Varnish is not a set-and-forget tool. As you add WordPress plugins, change themes, or modify permalink structures, your VCL may need updates. Keep your VCL in version control, write tests for every behavior, and review the cache hit ratio weekly. A well-maintained Varnish installation will serve your WordPress site reliably at any scale.
Sarah Kim
Systems administrator and WordPress hosting specialist. Has managed infrastructure at two managed WordPress hosting companies. Writes about server stacks, caching, and monitoring.