Varnish Cache is an HTTP reverse proxy that stores responses in memory and serves repeated requests without touching your application. For pages that are the same for every visitor, it can cut response times to a few milliseconds and remove most of the load from the backend. In this tutorial you will install Varnish on Ubuntu 24.04, put it on port 80 in front of Nginx, write a VCL configuration with a health check, cookie handling and cache purging, verify that responses are served from cache, and add HTTPS with Nginx in front of Varnish.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has sudo privileges.
  • At least 1 GB of RAM. Varnish keeps its cache in memory, and you'll assign it a fixed amount.
  • Nginx installed and serving your site on port 80. This guide assumes the default document root /var/www/html.
  • A domain name pointing to the server if you want to follow the HTTPS step. This guide uses your_domain as a placeholder.

The final layout looks like this:

ComponentListens onRole
Nginx (TLS):443Terminates HTTPS and forwards to Varnish
Varnish:80Caches responses, forwards misses to the backend
Nginx (backend)127.0.0.1:8080Serves the site or proxies to your application

Step 1 - Installing Varnish

Varnish is available in the Ubuntu 24.04 repositories:

sudo apt update
sudo apt install varnish

Check the installed version:

varnishd -V
varnishd (varnish-7.1.1 revision ...)
Copyright (c) 2006 Verdens Gang AS
Copyright (c) 2006-2022 Varnish Software

The package starts Varnish on port 6081 with the configuration in /etc/varnish/default.vcl. You'll move it to port 80 in Step 3, after freeing that port.

Step 2 - Moving Nginx to port 8080

Varnish needs port 80, so Nginx must listen somewhere else. Bind it to 127.0.0.1:8080 so the backend can only be reached through Varnish. Open the default site:

sudo nano /etc/nginx/sites-available/default

Change the two listen lines at the top of the server block:

server {
    listen 127.0.0.1:8080 default_server;

    root /var/www/html;
    index index.html index.htm index.nginx-debian.html;

    server_name _;

    location / {
        try_files $uri $uri/ =404;
    }
}

Remove the listen [::]:80 default_server; line, and change listen 80 in any other server block under /etc/nginx/sites-enabled/ the same way.

Varnish connects from 127.0.0.1, so Nginx logs would show that address for every request. Varnish adds the client's IP to the X-Forwarded-For header, and the realip module in Ubuntu's Nginx can restore it. real_ip_recursive on skips trusted local addresses in the header, which matters once Nginx also terminates HTTPS in front of Varnish (Step 7). Create this file:

sudo nano /etc/nginx/conf.d/real-ip.conf
set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

Test the configuration and restart Nginx (a restart is needed to release port 80):

sudo nginx -t
sudo systemctl restart nginx

Check that Nginx answers on the new port:

curl -sI http://127.0.0.1:8080/ | head -n 1
HTTP/1.1 200 OK

Your site is offline on port 80 until you finish Step 3.

Step 3 - Running Varnish on port 80

The listening port and cache size are set on the varnishd command line in the systemd unit. Look at the unit shipped by the package:

systemctl cat varnish

The ExecStart line looks similar to this:

ExecStart=/usr/sbin/varnishd \
	  -j unix,user=vcache \
	  -F \
	  -a :6081 \
	  -T localhost:6082 \
	  -f /etc/varnish/default.vcl \
	  -S /etc/varnish/secret \
	  -s malloc,256m

Don't edit that file, because package upgrades overwrite it. Create a drop-in override instead:

sudo systemctl edit varnish

In the editor, add the following between the comment lines. Copy the ExecStart from your own output and change only the -a and -s values. The empty ExecStart= line is required: it clears the original command before setting the new one.

[Service]
ExecStart=
ExecStart=/usr/sbin/varnishd \
	  -j unix,user=vcache \
	  -F \
	  -a :80 \
	  -T localhost:6082 \
	  -f /etc/varnish/default.vcl \
	  -S /etc/varnish/secret \
	  -s malloc,512m
  • -a :80 makes Varnish listen on port 80 on all addresses.
  • -s malloc,512m sets the cache size. Choose a value that leaves enough RAM for Nginx, your application and the operating system; Varnish uses somewhat more memory than this value for its own overhead.
  • -T localhost:6082 is the management interface used by varnishadm. Keep it on localhost.

Save and close the file. Don't restart Varnish yet; you'll load the new unit together with the new VCL in the next step.

Step 4 - Writing the VCL configuration

VCL (Varnish Configuration Language) decides what is cached and for how long. Varnish's built-in logic is already safe: it doesn't cache requests with a Cookie or Authorization header or responses with Set-Cookie, and it caches other 200 responses for 2 minutes unless the backend sends Cache-Control headers. Your VCL runs first and only needs to adjust that behavior.

Back up the default file and open it:

sudo cp /etc/varnish/default.vcl /etc/varnish/default.vcl.orig
sudo nano /etc/varnish/default.vcl

Replace its contents with the following:

vcl 4.1;

backend default {
    .host = "127.0.0.1";
    .port = "8080";
    .connect_timeout = 5s;
    .first_byte_timeout = 60s;
    .probe = {
        .url = "/";
        .timeout = 2s;
        .interval = 5s;
        .window = 5;
        .threshold = 3;
    }
}

acl purge {
    "localhost";
    "127.0.0.1";
    "::1";
}

sub vcl_recv {
    # Allow cache invalidation from the server itself
    if (req.method == "PURGE") {
        if (!client.ip ~ purge) {
            return (synth(405, "Not allowed"));
        }
        return (purge);
    }

    # Never cache Let's Encrypt challenges or admin areas
    if (req.url ~ "^/\.well-known/acme-challenge/" || req.url ~ "^/admin") {
        return (pass);
    }

    # Static files are the same for everyone: ignore cookies
    if (req.url ~ "\.(css|js|png|jpe?g|gif|svg|webp|avif|ico|woff2?)(\?.*)?$") {
        unset req.http.Cookie;
    }
}

sub vcl_backend_response {
    # Cache static files for a day and drop any cookies the backend sets on them
    if (bereq.url ~ "\.(css|js|png|jpe?g|gif|svg|webp|avif|ico|woff2?)(\?.*)?$") {
        unset beresp.http.Set-Cookie;
        set beresp.ttl = 1d;
    }

    # Keep expired objects for 6 hours to serve them if the backend is down
    set beresp.grace = 6h;
}

sub vcl_deliver {
    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
    } else {
        set resp.http.X-Cache = "MISS";
    }
}

What this configuration does:

  • Backend and probe. Varnish checks / every 5 seconds and considers the backend healthy when at least 3 of the last 5 checks succeed.
  • Purge. A PURGE request from the server removes that URL from the cache. Requests from other addresses get a 405.
  • Pass. Paths that must always reach the backend skip the cache. Add your own, such as /wp-admin or /cart, to this condition.
  • Static files. Removing cookies lets Varnish cache images, CSS and JavaScript even when the site sets tracking or session cookies.
  • Grace. If the backend is down or slow, Varnish serves the expired copy for up to 6 hours while it tries to fetch a fresh one.
  • X-Cache header. Shows whether a response was a hit or a miss, which you'll use to verify the setup.

Check that the VCL compiles before loading it:

sudo varnishd -C -f /etc/varnish/default.vcl > /dev/null && echo "VCL OK"
VCL OK

If there's a syntax error, varnishd prints the file, line and column of the problem instead.

Now apply the new unit and VCL:

sudo systemctl daemon-reload
sudo systemctl restart varnish

Confirm that Varnish listens on port 80:

sudo ss -tlnp | grep varnishd
LISTEN 0      1024         0.0.0.0:80        0.0.0.0:*    users:(("cache-main",pid=4321,fd=6))
LISTEN 0      1024            [::]:80           [::]:*    users:(("cache-main",pid=4321,fd=7))
LISTEN 0      10         127.0.0.1:6082      0.0.0.0:*    users:(("varnishd",pid=4310,fd=9))

If UFW is active and you haven't opened HTTP yet, allow it with sudo ufw allow 80/tcp.

Step 5 - Verifying that responses are cached

Request the same page twice and compare the headers:

curl -sI http://your_server_ip/ | grep -E "X-Cache|Age|Via"
curl -sI http://your_server_ip/ | grep -E "X-Cache|Age|Via"
X-Cache: MISS
Age: 0
Via: 1.1 your-hostname (Varnish/7.1)
X-Cache: HIT
Age: 3
Via: 1.1 your-hostname (Varnish/7.1)

The first request went to Nginx, the second came from memory. Age shows how many seconds the object has been in the cache.

Check the health of the backend:

sudo varnishadm backend.list
Backend name   Admin    Probe    Health     Last change
boot.default   probe    5/5      healthy    Thu, 25 Sep 2026 10:05:00 GMT

Look at the hit and miss counters:

varnishstat -1 -f MAIN.cache_hit -f MAIN.cache_miss
MAIN.cache_hit              1         0.00 Cache hits
MAIN.cache_miss             1         0.00 Cache misses

On a live site, a good hit ratio depends on your content, but if cache_miss grows as fast as cache_hit, find out why pages aren't cached. varnishlog shows every step of a request. This example follows requests for /:

sudo varnishlog -g request -q 'ReqURL eq "/"'

Look for VCL_call and VCL_return lines: pass or hit-for-pass tells you Varnish decided not to cache, and the request headers above them (usually Cookie) or the Set-Cookie and Cache-Control response headers show the reason. Press Ctrl+C to stop.

Step 6 - Purging and reloading

After you update a page, remove it from the cache with the PURGE method from the server:

curl -X PURGE http://127.0.0.1/

The response is a small HTML page with 200 Purged. The next request for / is a miss and fetches the new content.

When you change the VCL later, you don't need a restart, which would empty the cache. Compile the file as in Step 4, then reload:

sudo systemctl reload varnish

The reload loads the new VCL into the running Varnish and keeps the cached objects.

Step 7 - Adding HTTPS in front of Varnish

Varnish doesn't handle TLS. The usual solution is an Nginx server block on port 443 that terminates HTTPS and forwards requests to Varnish on port 80.

First get a certificate. Because Varnish passes /.well-known/acme-challenge/ to Nginx on port 8080, which serves /var/www/html, the Certbot webroot method works:

sudo apt install certbot
sudo certbot certonly --webroot -w /var/www/html -d your_domain -d www.your_domain

Create the TLS server block:

sudo nano /etc/nginx/sites-available/your_domain-tls
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name your_domain www.your_domain;

    ssl_certificate     /etc/letsencrypt/live/your_domain/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your_domain/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    location / {
        proxy_pass http://127.0.0.1:80;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto https;
    }
}

Enable it, open port 443 and reload Nginx:

sudo ln -s /etc/nginx/sites-available/your_domain-tls /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo ufw allow 443/tcp

Certbot's renewal timer reuses the webroot method. Add a deploy hook so Nginx loads renewed certificates:

sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/usr/bin/env bash
set -euo pipefail
systemctl reload nginx
sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo certbot renew --dry-run

Test HTTPS with curl -sI https://your_domain/ | grep -E "HTTP/|X-Cache". Two consecutive requests should show X-Cache: MISS and then HIT.

Troubleshooting

Varnish fails to start with "Could not get socket :80: Address already in use". Nginx or Apache is still listening on port 80. Find it with sudo ss -tlnp | grep ':80 ', fix its listen directives and restart it.

Every response is X-Cache: MISS. The request carries cookies or the backend sends Set-Cookie, Cache-Control: private, no-cache or max-age=0. Check the headers with curl -sI http://127.0.0.1:8080/ and with varnishlog, then remove unneeded cookies in vcl_recv or fix the headers in the application.

503 Backend fetch failed. Varnish can't reach Nginx on 127.0.0.1:8080, or the probe marks it as sick. Run sudo varnishadm backend.list and curl -I http://127.0.0.1:8080/. If the probe URL returns a redirect or an error, point .url to a path that returns 200.

Logged-in users see each other's pages. A page with personal content was cached. Make sure those URLs, or requests with the session cookie, reach return (pass) in vcl_recv, then purge the affected URLs.

Memory usage grows beyond the malloc value. That size covers cached objects only; each thread and object also has overhead. Lower the -s malloc value if the server starts swapping.

Conclusion

Varnish now serves your site on port 80 from memory, forwards misses to Nginx on port 8080, keeps serving stale content if the backend goes down, and sits behind Nginx for HTTPS. Watch varnishstat for a few days to see how much traffic is served from cache, and extend the pass and cookie rules as you learn how your application behaves. As next steps, set proper Cache-Control headers in your application, add purge calls to your deployment or CMS, and monitor the backend health from your monitoring system.