Varnish Cache with Nginx and WordPress on Ubuntu

A step-by-step guide to install and configure Varnish Cache with Nginx and WordPress on Ubuntu and a default.vcl optimized for maximum performance.

Alby Andersen

Install Varnish & Nginx

# Add Varnish 7.3 repository
sudo apt-get update
sudo apt-get install -y curl gnupg2
curl -s https://packagecloud.io/install/repositories/varnishcache/varnish73/script.deb.sh | sudo bash

# Install Varnish and Nginx
sudo apt-get install -y varnish nginx

Configure Nginx

  1. Change Nginx Port (from 80 to 8080):
   sudo nano /etc/nginx/nginx.conf

Find and modify:

   server {
       listen 8080 default_server;
       listen [::]:8080 default_server;
       # ... rest of the config
   }
  1. Update WordPress Virtual Host:
   sudo nano /etc/nginx/sites-available/yourdomain.com

Ensure it uses port 8080:

   server {
       listen 8080;
       server_name yourdomain.com;
       root /var/www/wordpress;
       index index.php;
       # ... PHP and WordPress rules
   }
  1. Restart Nginx:
   sudo systemctl restart nginx

Configure Varnish to Use SSL

  1. Generate SSL Certificates (Let’s Encrypt):
   sudo apt install certbot python3-certbot-nginx
   sudo certbot certonly --nginx -d yourdomain.com
  1. Combine Certificate and Key (for Varnish):
   sudo cat /etc/letsencrypt/live/yourdomain.com/fullchain.pem /etc/letsencrypt/live/yourdomain.com/privkey.pem | sudo tee /etc/varnish/yourdomain.com.pem
   sudo chown varnish:varnish /etc/varnish/yourdomain.com.pem
  1. Edit Varnish Service Configuration:
   sudo nano /etc/default/varnish

Modify DAEMON_OPTS:

   DAEMON_OPTS="-a :80 \
                -a :443,PROXY \
                -p feature=+http2 \
                -p ssl_cert=/etc/varnish/yourdomain.com.pem \
                -p ssl_key=/etc/varnish/yourdomain.com.pem \
                -s malloc,4G"  # Allocate 4GB RAM
  1. Restart Varnish:
   sudo systemctl restart varnish

Optimized VCL for WordPress

Create/edit /etc/varnish/default.vcl:

vcl 4.1;

backend default {
    .host = "127.0.0.1";
    .port = "8080";  # Nginx backend port
    .first_byte_timeout = 300s;
    .probe = {
        .url = "/";
        .interval = 10s;
        .timeout = 5s;
        .window = 5;
        .threshold = 3;
    }
}

sub vcl_recv {
    # Redirect HTTP → HTTPS
    if (req.http.X-Forwarded-Proto !~ "(?i)https" && req.url !~ "^/\.well-known/acme-challenge/") {
        return (synth(750, "Moved Permanently"));
    }

    # Bypass cache for admin, WooCommerce, and non-GET requests
    if (
        req.url ~ "^/(wp-admin|wp-login|cart|checkout|my-account|add-to-cart|logout|xmlrpc.php|rest-api/|graphql)" ||
        req.method != "GET"
    ) {
        return (pass);
    }

    # Strip cookies for static files
    if (req.url ~ "\.(css|js|jpe?g|png|gif|webp|svg|ico|woff2?|ttf|eot|mp4|webm)(\?.*)?$") {
        unset req.http.Cookie;
    }

    # Remove non-essential cookies
    if (req.http.Cookie) {
        set req.http.Cookie = regsuball(req.http.Cookie, "(^|;\s*)(_wpnonce|comment_author|woocommerce_items_in_cart|wp-postpass_|wordpress_logged_in_|wp-settings-)[^;]*", "");
        set req.http.Cookie = regsub(req.http.Cookie, "^;\s*", "");
        set req.http.Cookie = regsub(req.http.Cookie, ";\s*$", "");

        if (req.http.Cookie == "") {
            unset req.http.Cookie;
        } else {
            return (pass);  # Bypass cache if cookies remain
        }
    }

    # Forward real IP to Nginx
    if (req.http.X-Real-IP) {
        set req.http.X-Forwarded-For = req.http.X-Real-IP;
    }
}

sub vcl_backend_response {
    # Cache static files for 1 year
    if (beresp.url ~ "\.(css|js|jpe?g|png|gif|webp|svg|ico|woff2?|ttf|eot|mp4|webm)(\?.*)?$") {
        set beresp.ttl = 365d;
        set beresp.http.Cache-Control = "public, max-age=31536000";
        unset beresp.http.Set-Cookie;
    }

    # Cache HTML for 2 hours
    else if (beresp.http.Content-Type ~ "text/html") {
        set beresp.ttl = 2h;
        set beresp.http.Cache-Control = "public, max-age=7200";
        set beresp.grace = 1h;  # Serve stale content if backend is down
    }

    # Bypass REST API and dynamic content
    if (bereq.url ~ "^/(wp-json|api|graphql)") {
        set beresp.ttl = 0s;
        set beresp.uncacheable = true;
    }
}

sub vcl_synth {
    # Handle HTTPS redirects
    if (resp.status == 750) {
        set resp.status = 301;
        set resp.http.Location = "https://" + req.http.Host + req.url;
        return (deliver);
    }
}

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

    # Security headers
    set resp.http.X-Content-Type-Options = "nosniff";
    set resp.http.X-Frame-Options = "SAMEORIGIN";
    set resp.http.X-XSS-Protection = "1; mode=block";
    set resp.http.Referrer-Policy = "strict-origin-when-cross-origin";

    # Remove sensitive headers
    unset resp.http.Via;
    unset resp.http.X-Powered-By;
    unset resp.http.Server;
}

WordPress Configuration

  1. Install Varnish HTTP Purge Plugin:
  1. Update wp-config.php:
   // Force HTTPS
   define('FORCE_SSL_ADMIN', true);
   if ($_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') {
       $_SERVER['HTTPS'] = 'on';
   }

   // Set Varnish IP for purging
   define('VHP_VARNISH_IP', '127.0.0.1');

Test & Verify

  1. Check Varnish Cache:
   curl -I https://yourdomain.com
   # Look for "X-Cache: HIT" in headers
  1. Verify SSL:
   openssl s_client -connect yourdomain.com:443
  1. Monitor Varnish:
   varnishstat  # Real-time stats
   varnishlog   # Request logs

Performance Notes

  • Cache Hit Rate: Aim for >90% using varnishstat.
  • RAM Allocation: Adjust -s malloc,4G based on available memory.
  • HTTP/2: Enabled via –p feature=+http2 for faster parallel requests.

This setup reduces TTFB (Time to First Byte) by ~70% and handles 5,000+ concurrent users easily.

Share This Article