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
sudoprivileges. - 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_domainas a placeholder.
The final layout looks like this:
| Component | Listens on | Role |
|---|---|---|
| Nginx (TLS) | :443 | Terminates HTTPS and forwards to Varnish |
| Varnish | :80 | Caches responses, forwards misses to the backend |
| Nginx (backend) | 127.0.0.1:8080 | Serves 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 :80makes Varnish listen on port 80 on all addresses.-s malloc,512msets 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:6082is the management interface used byvarnishadm. 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
PURGErequest from the server removes that URL from the cache. Requests from other addresses get a405. - Pass. Paths that must always reach the backend skip the cache. Add your own, such as
/wp-adminor/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.
ImportantIf your application behaves differently for HTTP and HTTPS (for example, it redirects to HTTPS based on
X-Forwarded-Proto), addhash_data(req.http.X-Forwarded-Proto);in avcl_hashsubroutine so Varnish caches the two variants separately. Otherwise a cached redirect can be served to HTTPS visitors and cause a redirect loop.
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.
