Control panels

Configuring advanced caching layers with Varnish on top of Nginx

Put Varnish in front of Nginx as an HTTP accelerator: install it, write a VCL that caches the right pages, terminate TLS in Nginx and confirm real cache hits.

AdvancedLinux server5 min read

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

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:

  1. Client connects to Nginx on TCP 443.
  2. Nginx proxies the request to Varnish on 127.0.0.1:6081.
  3. On a miss, Varnish fetches from the origin Nginx server block on 127.0.0.1:8080.
  4. 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.

Bash
sudo apt update
sudo apt install -y varnish

On AlmaLinux or Rocky Linux use sudo dnf install -y varnish. Confirm the binary:

Bash
varnishd -V

Now set the listen address. The packaged systemd unit varies between distributions, so use a drop-in override — it works everywhere and survives package upgrades.

Bash
sudo systemctl edit varnish

Add the following, replacing the memory size with roughly 10–20% of your VPS RAM:

INI
[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,256m

On 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:

Bash
sudo systemctl daemon-reload
sudo systemctl restart varnish
sudo systemctl enable varnish

Write 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
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:

Bash
sudo varnishd -C -f /etc/varnish/default.vcl
sudo systemctl reload varnish

Tip: 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.

Nginx
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:

Nginx
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:

Bash
sudo nginx -t
sudo systemctl reload nginx

Warning: 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:

Bash
curl -sI https://example.com/ | grep -i x-cache
curl -sI https://example.com/ | grep -i x-cache

The 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:

Bash
varnishstat -1 | grep -E "cache_hit|cache_miss"

And watch live traffic to see which URLs are being cached:

Bash
varnishlog -g request -q "ReqURL ~ '^/'"

Troubleshooting

  • Every request is a MISS. The backend is sending Set-Cookie on pages you expect to cache, or a cookie is reaching `vcl_rec
Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.