Varnish cache with Nginx is the standard way to serve dynamic pages from memory instead of rebuilding them on every request. In this guide you will install Varnish on a Linux VPS, place it in front of Nginx, write a VCL that caches public pages while skipping anything with a session cookie, and confirm real cache hits with curl and varnishstat.
The result is a three-layer stack: Nginx terminates TLS, Varnish holds cached responses in RAM, and a second Nginx server block on localhost generates the content.
Before you start
- A Linux VPS running Ubuntu 22.04/24.04, Debian 12 or AlmaLinux/Rocky 8–9, with root or sudo access.
- Nginx already installed and serving your site. If not, start with Install Nginx and host your first website.
- Your document root and PHP-FPM socket path, for example
/run/php/php8.3-fpm.sock. - A valid TLS certificate if the site runs HTTPS — see Secure Nginx with a free Let's Encrypt SSL certificate.
Warning: You are moving the public listening port from Nginx to Varnish. A broken VCL takes the site offline. Test on a staging server first, or keep a second SSH session open so you can revert the config.
Plan the port layout
Varnish does not speak TLS, so Nginx stays in front and does the handshake. The traffic path becomes:
- Client connects to Nginx on TCP 443.
- Nginx proxies the request to Varnish on
127.0.0.1:6081. - On a miss, Varnish fetches from the origin Nginx server block on
127.0.0.1:8080. - Varnish stores the response in memory and delivers it.
Both Varnish and the origin Nginx bind to localhost only, so nothing is exposed except Nginx on 443. That keeps the attack surface the same as before.
Install Varnish and point it at Nginx
Install the package from your distribution's repositories. The version you get (6.x or 7.x) both accept VCL 4.1.
sudo apt update
sudo apt install -y varnishOn AlmaLinux or Rocky Linux use sudo dnf install -y varnish. Confirm the binary:
varnishd -VNow set the listen address. The packaged systemd unit varies between distributions, so use a drop-in override — it works everywhere and survives package upgrades.
sudo systemctl edit varnishAdd the following, replacing the memory size with roughly 10–20% of your VPS RAM:
[Service]
ExecStart=
ExecStart=/usr/sbin/varnishd -a 127.0.0.1:6081 -T 127.0.0.1:6082 -f /etc/varnish/default.vcl -S /etc/varnish/secret -s malloc,256mOn RHEL-family systems you can instead edit /etc/varnish/varnish.params and set VARNISH_LISTEN_ADDRESS=127.0.0.1 and VARNISH_LISTEN_PORT=6081. Check which file your unit reads with systemctl cat varnish.
Apply and start:
sudo systemctl daemon-reload
sudo systemctl restart varnish
sudo systemctl enable varnishWrite the VCL
Open /etc/varnish/default.vcl and replace the contents. The backend points at the origin Nginx block you will create in the next step.
vcl 4.1;
import std;
backend default {
.host = "127.0.0.1";
.port = "8080";
.connect_timeout = 5s;
.first_byte_timeout = 60s;
.between_bytes_timeout = 60s;
}
acl purgers {
"127.0.0.1";
"::1";
}
sub vcl_recv {
# Only cache safe methods
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# Explicit purge endpoint, restricted by ACL
if (req.method == "PURGE") {
if (!client.ip ~ purgers) {
return (synth(405, "Not allowed"));
}
return (purge);
}
# Never cache admin, cart or checkout paths
if (req.url ~ "^/(admin|wp-admin|cart|checkout|my-account)") {
return (pass);
}
# Skip the cache for logged-in sessions
if (req.http.Cookie ~ "(wordpress_logged_in|woocommerce_items_in_cart|PHPSESSID)") {
return (pass);
}
if (req.http.Authorization) {
return (pass);
}
# Normalise the host so www and non-www share one cache entry
set req.http.Host = regsub(req.http.Host, "^www\.", "");
unset req.http.Cookie;
unset req.http.Authorization;
return (hash);
}
sub vcl_backend_response {
# Anything that sets a cookie is per-user: do not cache it
if (beresp.http.Set-Cookie) {
set beresp.uncacheable = true;
set beresp.ttl = 120s;
return (deliver);
}
# Serve stale content while the backend is slow or restarting
set beresp.grace = 24h;
set beresp.keep = 24h;
if (beresp.status == 404 || beresp.status == 500) {
set beresp.ttl = 1m;
}
if (beresp.ttl <= 0s) {
set beresp.ttl = 2m;
}
return (deliver);
}
sub vcl_deliver {
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
set resp.http.X-Cache-Hits = obj.hits;
unset resp.http.X-Varnish;
unset resp.http.Via;
}Check the VCL compiles before reloading — this catches syntax errors without dropping traffic:
sudo varnishd -C -f /etc/varnish/default.vcl
sudo systemctl reload varnishTip: Keep the X-Cache header while tuning, then remove it in vcl_deliver once you are happy. It tells you instantly whether a page is being served from memory.
Terminate TLS in Nginx
You now need two server blocks. The first listens publicly on 443 and proxies everything to Varnish. The second listens on 127.0.0.1:8080 and is the real origin.
server {
listen 80;
server_name example.com;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:6081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}The origin block binds to localhost only. Varnish appends the client IP to X-Forwarded-For automatically, so Nginx can recover the real address:
server {
listen 127.0.0.1:8080;
server_name example.com;
set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
root /var/www/example.com/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
}Test and reload:
sudo nginx -t
sudo systemctl reload nginxWarning: If the origin block still listens on 0.0.0.0:80 or 0.0.0.0:443, clients can bypass Varnish and hit the backend directly. Bind it to 127.0.0.1:8080 only.
Check it works
Request the same URL twice and watch the header change:
curl -sI https://example.com/ | grep -i x-cache
curl -sI https://example.com/ | grep -i x-cacheThe first response should be X-Cache: MISS, the second X-Cache: HIT. A logged-in page should always return MISS.
Confirm the counters are moving:
varnishstat -1 | grep -E "cache_hit|cache_miss"And watch live traffic to see which URLs are being cached:
varnishlog -g request -q "ReqURL ~ '^/'"Troubleshooting
- Every request is a MISS. The backend is sending
Set-Cookieon pages you expect to cache, or a cookie is reaching `vcl_rec
