Skip to content
FT

Faruk Turkovic

1 article

December 15, 2025

Building Caching Layers with Varnish

Software Development

Building Caching Layers with Varnish

Introduction Caching is an essential factor in most modern web applications, as it improves performance and provides a smooth user experience.  When it comes to caching, Redis is often the go-to tool for most web applications. However, in this article, I will cover a lesser-known but incredibly powerful alternative: Varnish. The Importance of Caching In modern-day applications with high traffic volumes, performance is everything, as delays in serving content can lead to frustration and user loss. Several factors, such as slow database queries or heavy computations, can contribute to delays and slow load times, especially when simply fixing or speeding up the process isn’t an option, making this scenario an ideal candidate for caching. Caching is the process of storing frequently accessed data, responses, or any expensive computations in a separate layer for fast retrieval, without re-computating or re-fetching the data from the original source. What is Varnish and Why Use It? Varnish is a HTTP reverse proxy and a caching server that speeds up web applications by caching HTTP responses. You simply put it in front of your server that speaks HTTP and configure it to cache responses using VCL (Varnish Configuration Language). Varnish primarily uses an in-memory cache store, meaning it stores HTTP responses in RAM, which makes retrievals extremely fast. This makes it ideal for caching static content or content that doesn’t change too often, such as HTML pages and API responses. VCL - Varnish Configuration Language  VCL (Varnish Configuration Language) is a domain-specific language that tells Varnish how to handle incoming HTTP requests. Varnish turns VCL code into binary code, which is executed when requests arrive. The file containing VCL code is organized into subroutines, which are sets of instructions executed at different times of the request/response lifecycle.  Even though VCL is more limited than traditional code, it still allows a certain level of customization, allowing us to reconfigure existing subroutines, overriding the default Varnish behaviour, and even write our own functions we can call inside those subroutines. Varnish Workflow Varnish follows a simple, yet powerful workflow:  When the client makes a request, Varnish checks if a response associated with that request already exists in the cache. Based on that check, the following scenarios can occur: 1. Cache Hit:  The requested content exists inside the cache, and Varnish returns the content immediately without contacting the backend 2. Cache Miss:  The requested content is not in the cache, so Varnish forwards the request to the backend, saving the response for future requests and delivering it to the client Cache Hit configuration inside the vcl_hit subroutine: sub vcl_hit { if (obj.ttl > 0s) { # Object is fresh, serve it from cache return (deliver); } else if (obj.ttl + obj.grace > 0s) { # Object is stale but within grace period # force background refresh, while serving stale content set req.hash_always_miss = true; return (deliver); } else { return (miss); } } Cache Miss configuration inside the vcl_miss subroutine: sub vcl_miss { # Cache miss, fetch from backend return (fetch); } The open-source version of Varnish does not natively support TLS/SSL termination for incoming connections. This means it only accepts HTTP requests from clients, and for HTTPS traffic, you need an additional TLS termination layer (e.g., Nginx) in front of Varnish.  Varnish doesn’t natively terminate TLS because its sole purpose is caching and request handling. TLS termination requires managing certificates, handshakes, and encryption overhead, which would complicate Varnish and potentially reduce performance. Since traffic from the TLS termination layer to Varnish happens over HTTP, it should be done over a secure network. Alternatively, you can run both the TLS terminator and Varnish in the same Docker network or on the same machine, and have them communicate over localhost or the internal Docker network. The Varnish Enterprise version offers native support for TLS termination, providing built-in HTTPS handling without requiring a TLS termination layer. Cache Keys in Varnish Cache keys are unique identifiers used to store and retrieve responses from Varnish. In simpler terms, the cache key is what tells Varnish if a request has been seen before and has a stored response. For the generation of cache keys, Varnish uses a combination of the request URL and host headers, so if a user were to make a request to: http://www.example-url.com/users?id=1 Varnish would use something like this (including the query parameters) as the cache key. Different query parameters produce different cache entries, so:  /users?id=1 /users?id=2 /users?id=3 will each be stored as separate cache entries. In VCL, you can modify key generation also to use other request data, such as cookies or custom headers. Since cookies often personalize content, Varnish ignores them by default. Cache Invalidation in Varnish The main cache invalidation techniques in Varnish are: 1. TTL (Time-To-Live):  defines how long an object stays fresh in the cache 2. Grace: defines how long after TTL the stale content is kept while waiting for the backend to serve fresh data 3. Keep: defines how long after grace the content is being kept, and is generally used for conditional revalidation 4. PURGE Requests: allows manual invalidation of cached objects from an admin user or an application before TTL expiry We set TTL, Grace and Keep inside the vcl_backend_response subroutine: sub vcl_backend_response { # In this example, we’re caching successful responses if (beresp.status == 200) { set beresp.ttl = 10m; set beresp.grace = 5m; set beresp.keep = 1m; # for most cases, TTL + grace is sufficient } ... return (deliver); } To allow safe PURGE requests, inside our configuration file, we define an Access Control List (ACL) where we specify which IP addresses can send PURGE requests: ​​acl purge { "127.0.0.1"; } Additionally, in the vcl_recv subroutine, we check for PURGE requests and if the client IP matches the one defined in the ACL:  sub vcl_recv { if (req.method == "PURGE") { if (!client.ip ~ purge) { return (synth(405, "Not allowed.")); } return (purge); } ... return (hash): } If the cache ever becomes full, Varnish automatically removes items by following the LRU (Least Recently Used) principle. This ensures that the most recently accessed items remain, while removing the least recently accessed ones, and is what we refer to as an eviction policy. Building a Caching Layer The first steps to building a caching layer using Varnish is to write a VCL configuration file. In this article, I will cover the basics. For more details and a more production-ready configuration, refer to the official Varnish documentation. For simplicity, let’s assume we are using Varnish to cache API responses. Let’s start by defining the backend: backend default { .host = "127.0.0.1"; .port = "8080"; } In addition to this, we can define multiple backends, health probes to check if the backend is healthy, timeouts, max connections, etc. Moving on, we will cover request processing, using the vcl_recv subroutine. Under normal circumstances, Varnish caches only GET and HEAD requests. sub vcl_recv { if (req.method == "PURGE") { if (!client.ip ~ purge) { return (synth(405, "Not allowed.")); } return (purge); } if (req.method != "GET" && req.method != "HEAD") { return (pass); } return (hash); } For simplicity, I will only use the request URL to build the cache key: sub vcl_hash { hash_data(req.url); return (lookup); } Putting everything I have covered so far into a default.vcl file: vcl 4.1; # *********************** # Backend Configuration # *********************** backend default { # IP and port of backend server .host = "127.0.0.1"; .port = "8080"; } # *********************** # Access Control List # *********************** acl purge { # Allow PURGE request from trusted IP addresses "127.0.0.1"; } # *********************** # Request Processing # *********************** sub vcl_recv { # Check for PURGE request and if IP sending the request matches ACL if (req.method == "PURGE") { if (!client.ip ~ purge) { return (synth(405, "Not allowed.")); } return (purge); } # Forward request other than GET and HEAD to the backend if (req.method != "GET" && req.method != "HEAD") { return (pass) } return (hash); } # *********************** # Cache Key Configuration # *********************** sub vcl_hash { # Use only the URL for the cache key hash_data(req.url); return (lookup); } # *********************** # Cache Hit/Miss Handling # *********************** sub vcl_hit { # If object is fresh, deliver from cache if (obj.ttl > 0s) { return (deliver); } # If object is stale but within grace, deliver from cache # while revalidating in background else if (obj.ttl + obj.grace > 0s) { set req.has_always_miss = true; return (deliver); } # If object is expired past grace, fetch fresh data else { return (miss); } } sub vcl_miss { # Fetch data from backend if cache misses return (fetch); } # *********************** # Response Handling # *********************** sub vcl_backend_response { # Cache only successful responses if (beresp.status == 200) { set beresp.ttl = 10m; # Keep object fresh for 10 minutes set beresp.grace = 5m; # Keep stale object for 5 minutes } return (deliver); } # *********************** # Response Delivery # *********************** sub vcl_deliver { # Set custom headers for debugging if (obj.hits > 0) { if (obj.ttl <= 0s && (obj.ttl + obj.grace >= 0)) { # Object is stale - revalidating in background set resp.http.X-Cache = "STALE"; } # Object is fresh set resp.http.X-Cache = "HIT"; } else { # Object is not in cache set resp.http.X-Cache = "MISS"; } return (deliver); } Now that we have our VCL configuration ready, the easiest way to get started with testing is using Docker. I won’t be going into the setup, as this isn’t a Docker tutorial. However, you can find plenty of guides online or visit the Varnish Docker Hub for more details. Once we have everything running, instead of making requests to the API, we will be making requests to the Varnish server.  Debugging Let’s assume our Varnish server is running port 6081, and on our API we have an endpoint that returns a list of users.  For starters, we can run the following command to send a HEAD request and fetch the response headers from that endpoint: curl -I http://localhost:6081/api/users Inside our terminal, we should see something like: HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 Server: Varnish X-Cache: MISS To verify caching works, on the first request or after TTL and grace expiry, inspecting the response headers, we should see: X-Cache: MISS Subsequent requests, if they TTL hasn’t expired, should return: X-Cache: HIT After TTL expiry, but within grace, first request should return: X-Cache: STALE Other than just inspecting the response headers, Varnish offers its own debugging tools: varnishstat:  provides a real-time overview of Varnish’s performance metrics like request rates, cache hits and misses, backend fetches, etc. varnishlog: provides detailed information about HTTP requests as they go through Varnish and is used for pinpointing issues within the cache flow varnishtop: displays a list of the most frequent log entries, helping identify patterns and common issues Conclusion Varnish is a powerful solution for speeding up web traffic and taking loads off the server, improving response time and scalability.  Unlike most caching services, which are configured within the application, Varnish operates at the HTTP layer, which means it is its own entity and it handles caching without any or with minimal application changes. Furthermore, Varnish supports advanced caching features such as customizable cache keys, request and response configuration, and built-in support for grace and stale content delivery, which many systems don't support out of the box.  Combining Varnish with other caching systems, such as Redis, allows for leveraging the strengths of both. By using Varnish to cache HTTP responses at the edge and Redis for application-level caching, a robust caching strategy is created for most modern systems. References Varnish Documentation (FOSS)   Varnish Documentation (Enterprise) VCL - Varnish Configuration Language Varnish Docker Hub "Building Caching Layers with Varnish" Tech Bite was brought to you by Faruk Turković, Junior Software Engineer at Atlantbh. (more…)

Ready to Achieve More?

We’ll help you reach your goals quickly with an easy and straightforward process to kick off our collaboration. Here’s what happens next.

STEP 1

Discovery Call

Let’s chat to understand your company, project needs, and answer any questions along the way.

STEP 2

Free Consultation

Work closely with our experts to explore the right solutions for your business.

STEP 3

Collaboration Proposal

We'll recommend the best strategy for your goals, ensuring you get the most from our expertise.

STEP 4

30-Day Cancellation
Policy Contract

Spoiler: It’s Never Been Used

Enjoy peace of mind while we deliver excellence from day one—our track record speaks for itself.

Services you're interested in (Optional)