Varnish Cache Configuration: A Practical Guide to VCL and Caching Strategy
Varnish is a powerful HTTP accelerator, but the configuration language (VCL) has a steep learning curve. Here's how to configure Varnish for real workloads — including cache key design, TTL strategy, authentication handling, and ESI for partial caching.
Varnish Cache sits in front of your web servers and caches responses in memory. For content-heavy sites with high read traffic, a correctly configured Varnish instance absorbs most of the load that would otherwise hit your application servers. The challenge is "correctly configured" — Varnish's Varnish Configuration Language (VCL) is C-like and the default behavior needs adjustment for most real workloads.
The VCL request lifecycle
Understanding Varnish means understanding its state machine. A request goes through several subroutines:
vcl_recv: request arrives; decide whether to cache, pass to backend, or return an errorvcl_hash: compute the cache lookup keyvcl_hit: cache hit; decide whether to use the cached responsevcl_miss: cache miss; decide whether to fetch from backendvcl_backend_response: backend responds; decide whether to cache the responsevcl_deliver: send response to client
You override behavior by adding logic to these subroutines in your VCL file.
The minimum config that makes Varnish useful
Default Varnish won't cache requests with cookies. Since most modern applications set cookies, this means nothing gets cached by default. The first VCL customisation: strip cookies for assets that don't need them.
sub vcl_recv {
// Strip cookies for static assets
if (req.url ~ "\.(css|js|png|jpg|jpeg|gif|ico|woff|woff2|svg)$") {
unset req.http.Cookie;
}
// Strip cookies for public pages that don't personalise based on session
if (req.url ~ "^/blog/" || req.url ~ "^/about") {
unset req.http.Cookie;
}
}
With cookies stripped, Varnish will serve these from cache for all users.
Cache key design
The default cache key is Host + URL. For most applications this is correct, but some workloads need adjustment.
Vary headers: if your application serves different content based on Accept-Encoding or Accept-Language, the Vary response header tells Varnish to cache separate versions per value. A Vary: Accept-Encoding header means Varnish caches both the gzip and non-gzip versions of each URL separately. Too many Vary headers fragment the cache and reduce hit rates.
Normalise query strings: URLs with different query parameter orderings are cached separately by default. ?sort=name&page=2 and ?page=2&sort=name produce different cache entries. Normalise query strings to increase cache hits:
sub vcl_recv {
// Sort query string parameters for consistent cache keys
set req.url = regsuball(req.url, "([?&])utm_[^&]+&?", "\1"); // strip UTM params
set req.url = regsub(req.url, "(\?[^#]+)#.*$", "\1"); // strip fragment
}
TTL strategy
Varnish caches based on the Cache-Control header from your backend. If your backend sends Cache-Control: max-age=300, Varnish caches for 5 minutes. If the backend doesn't send a caching header, Varnish uses its default_ttl (120 seconds by default).
In vcl_backend_response, you can override or extend TTLs:
sub vcl_backend_response {
// Cache 404s briefly — prevents cache stampede on missing assets
if (beresp.status == 404) {
set beresp.ttl = 30s;
}
// Never cache 5xx errors
if (beresp.status >= 500) {
set beresp.uncacheable = true;
return (deliver);
}
// Extend TTL for slow-changing content from backend
if (bereq.url ~ "^/api/product-catalogue") {
set beresp.ttl = 600s;
set beresp.grace = 120s; // serve stale for 2 minutes if backend is down
}
}
Grace mode is underused and valuable. beresp.grace = 120s tells Varnish: "if the cached object is expired but the backend is slow or unavailable, serve the stale object for up to 2 minutes". This prevents users from seeing errors during backend restarts or traffic spikes.
Handling authenticated content
Content behind authentication should not be served from cache to unauthenticated users. The standard approach:
sub vcl_recv {
// Pass authenticated requests directly to backend
if (req.http.Authorization || req.http.Cookie ~ "session=") {
return (pass);
}
}
return (pass) sends the request to the backend without caching the response and without serving from cache. The backend handles authentication; Varnish is bypassed for these requests.
For applications with a mix of public and personalised content on the same page, Edge Side Includes (ESI) allow caching the public parts:
sub vcl_backend_response {
set beresp.do_esi = true; // enable ESI processing
}
Your backend includes ESI tags in the HTML:
<esi:include src="/api/user-header" />
<esi:include src="/content/article-body" ttl="3600" />
Varnish assembles the final response by fetching and caching each included fragment independently. The article body is cached for an hour; the user header fragment is not cached (or cached briefly). The result: most of the page comes from cache; only the personalised fragment hits the backend.
Purging cached content
When content changes, you need to invalidate the cached version. Varnish provides several mechanisms:
BAN: mark all cached objects matching an expression as invalid
varnishadm ban req.url ~ ^/blog/new-post
Surrogate keys / cache tags: a more precise purging mechanism. Your backend sends a header like Xkey: product-123 category-electronics with the response. Varnish stores this and supports purging all objects tagged with a specific key:
sub vcl_backend_response {
if (beresp.http.Xkey) {
set beresp.http.Xkey = beresp.http.Xkey;
}
}
// Purge by tag (requires the xkey VMOD)
sub vcl_recv {
if (req.method == "PURGE") {
if (req.http.Xkey-Purge) {
ban("obj.http.Xkey ~ " + req.http.Xkey-Purge);
}
}
}
Instant purge: send a PURGE request for a specific URL:
curl -X PURGE http://varnish-host/blog/specific-post
In production, restrict purge requests to trusted internal IPs.
Monitoring what Varnish is doing
varnishstat shows real-time statistics: cache hit ratio, request rate, backend health. The metric to watch first: cache_hit / (cache_hit + cache_miss). A well-configured Varnish instance handling content workloads should have a hit ratio above 90%.
varnishlog shows request-level logs with the cache decision for each request. Essential for debugging why specific requests aren't being cached.
Tuning a Varnish configuration or designing a caching strategy for a high-traffic site? Happy to work through the specifics.
Related
Platform Observability: Building the Visibility Layer Every Application Team Gets Automatically
How platform teams build and operate the metrics, logging, and tracing infrastructure that application teams consume without configuring — and what good platform observability coverage actually looks like.
Measuring Platform Engineering Success: The Metrics That Actually Matter
DORA metrics, developer experience scores, cognitive load measures, and the platform health indicators that tell you whether your internal platform is creating value or creating another bottleneck.
Designing an Internal Developer Platform: Abstraction Layers, Golden Paths, and the Governance Balance
How to design the abstraction layer between your platform and your application teams — what to hide, what to expose, and how to build the golden paths that make good practices the easy practices.
Working on this in production?
We do this work directly alongside engineering teams — architecture review, migration, and hands-on enablement.