Category: Cache

  • Complete Guide: Setting Up Varnish Cache for WordPress with NGINX

    Complete Guide: Setting Up Varnish Cache for WordPress with NGINX

    Using Varnish Cache in front of a WordPress site can drastically improve performance, reduce server load, and serve content lightning fast — especially when paired with NGINX and Cloudflare. Below is a complete and production-ready setup, including all configuration files and best practices.

    Requirements

    • A Linux server (Ubuntu/Debian/CentOS)
    • WordPress installed and working
    • NGINX (used as SSL terminator + PHP backend)
    • PHP-FPM (for dynamic content)
    • Varnish installed
    • Cloudflare (optional but supported)

    1. Varnish Configuration (default.vcl)

    Location: usually in /etc/varnish/default.vcl
    Varnish Port: here we use :6081 for external and :6216 for internal access

    vcl 4.0;
    
    import std;
    import proxy;
    
    
    # Define individual backends
    backend default {
        .host = "127.0.0.1";
        .port = "6216";  # Use 80 after SSL termination
    
    }
    
    # Add hostnames, IP addresses and subnets that are allowed to purge content
    acl purge {
        "YOUR_SERVER_IP";
        "173.245.48.0"/20; # Cloudflare IPs
        "103.21.244.0"/22;  # Cloudflare IPs
        "103.22.200.0"/22;  # Cloudflare IPs
        "103.31.4.0"/22;        # Cloudflare IPs        
        "141.101.64.0"/18;          # Cloudflare IPs
        "108.162.192.0"/18;     # Cloudflare IPs
        "190.93.240.0"/20;      # Cloudflare IPs
        "188.114.96.0"/20;          # Cloudflare IPs
        "197.234.240.0"/22;     # Cloudflare IPs
        "198.41.128.0"/17;      # Cloudflare IPs
        "162.158.0.0"/15;       # Cloudflare IPs
        "104.16.0.0"/13;        # Cloudflare IPs
        "104.24.0.0"/14;        # Cloudflare IPs
        "172.64.0.0"/13;        # Cloudflare IPs
        "131.0.72.0"/22;        # Cloudflare IPs
    }
    
    sub vcl_recv {
        set req.backend_hint = default;
    
        # Remove empty query string parameters
        # e.g.: www.example.com/index.html?
        if (req.url ~ "\?$") {
            set req.url = regsub(req.url, "\?$", "");
        }
    
        # Remove port number from host header
        set req.http.Host = regsub(req.http.Host, ":[0-9]+", "");
    
        # Sorts query string parameters alphabetically for cache normalization purposes
        set req.url = std.querysort(req.url);
    
        # Remove the proxy header to mitigate the httpoxy vulnerability
        # See https://httpoxy.org/
        unset req.http.proxy;
    
        # Add X-Forwarded-Proto header when using https
        if (!req.http.X-Forwarded-Proto) {
            if(std.port(server.ip) == 443 || std.port(server.ip) == 8443) {
                set req.http.X-Forwarded-Proto = "https";
            } else {
                set req.http.X-Forwarded-Proto = "http";
            }
        }
    
        # Purge logic to remove objects from the cache.
        # Tailored to the Proxy Cache Purge WordPress plugin
        # See https://wordpress.org/plugins/varnish-http-purge/
        if(req.method == "PURGE") {
            if(!client.ip ~ purge) {
                return(synth(405,"PURGE not allowed for this IP address"));
            }
            if (req.http.X-Purge-Method == "regex") {
                ban("obj.http.x-url ~ " + req.url + " && obj.http.x-host == " + req.http.host);
                return(synth(200, "Purged"));
            }
            ban("obj.http.x-url == " + req.url + " && obj.http.x-host == " + req.http.host);
            return(synth(200, "Purged"));
        }
    
        # Only handle relevant HTTP request methods
        if (
            req.method != "GET" &&
            req.method != "HEAD" &&
            req.method != "PUT" &&
            req.method != "POST" &&
            req.method != "PATCH" &&
            req.method != "TRACE" &&
            req.method != "OPTIONS" &&
            req.method != "DELETE"
        ) {
            return (pipe);
        }
    
        # Remove tracking query string parameters used by analytics tools
        if (req.url ~ "(\?|&)(_branch_match_id|_bta_[a-z]+|_bta_c|_bta_tid|_ga|_gl|_ke|_kx|campid|cof|customid|cx|dclid|dm_i|ef_id|epik|fbclid|gad_source|gbraid|gclid|gclsrc|gdffi|gdfms|gdftrk|hsa_acc|hsa_ad|hsa_cam|hsa_grp|hsa_kw|hsa_mt|hsa_net|hsa_src|hsa_tgt|hsa_ver|ie|igshid|irclickid|matomo_campaign|matomo_cid|matomo_content|matomo_group|matomo_keyword|matomo_medium|matomo_placement|matomo_source|mc_[a-z]+|mc_cid|mc_eid|mkcid|mkevt|mkrid|mkwid|msclkid|mtm_campaign|mtm_cid|mtm_content|mtm_group|mtm_keyword|mtm_medium|mtm_placement|mtm_source|nb_klid|ndclid|origin|pcrid|piwik_campaign|piwik_keyword|piwik_kwd|pk_campaign|pk_keyword|pk_kwd|redirect_log_mongo_id|redirect_mongo_id|rtid|s_kwcid|sb_referer_host|sccid|si|siteurl|sms_click|sms_source|sms_uph|srsltid|toolid|trk_contact|trk_module|trk_msg|trk_sid|ttclid|twclid|utm_[a-z]+|utm_campaign|utm_content|utm_creative_format|utm_id|utm_marketing_tactic|utm_medium|utm_source|utm_source_platform|utm_term|vmcid|wbraid|yclid|zanpid)=") {
            set req.url = regsuball(req.url, "(_branch_match_id|_bta_[a-z]+|_bta_c|_bta_tid|_ga|_gl|_ke|_kx|campid|cof|customid|cx|dclid|dm_i|ef_id|epik|fbclid|gad_source|gbraid|gclid|gclsrc|gdffi|gdfms|gdftrk|hsa_acc|hsa_ad|hsa_cam|hsa_grp|hsa_kw|hsa_mt|hsa_net|hsa_src|hsa_tgt|hsa_ver|ie|igshid|irclickid|matomo_campaign|matomo_cid|matomo_content|matomo_group|matomo_keyword|matomo_medium|matomo_placement|matomo_source|mc_[a-z]+|mc_cid|mc_eid|mkcid|mkevt|mkrid|mkwid|msclkid|mtm_campaign|mtm_cid|mtm_content|mtm_group|mtm_keyword|mtm_medium|mtm_placement|mtm_source|nb_klid|ndclid|origin|pcrid|piwik_campaign|piwik_keyword|piwik_kwd|pk_campaign|pk_keyword|pk_kwd|redirect_log_mongo_id|redirect_mongo_id|rtid|s_kwcid|sb_referer_host|sccid|si|siteurl|sms_click|sms_source|sms_uph|srsltid|toolid|trk_contact|trk_module|trk_msg|trk_sid|ttclid|twclid|utm_[a-z]+|utm_campaign|utm_content|utm_creative_format|utm_id|utm_marketing_tactic|utm_medium|utm_source|utm_source_platform|utm_term|vmcid|wbraid|yclid|zanpid)=[-_A-z0-9+(){}%.*]+&?", "");
            set req.url = regsub(req.url, "[?|&]+$", "");
        }
    
        # Only cache GET and HEAD requests
        if (req.method != "GET" && req.method != "HEAD") {
            set req.http.X-Cacheable = "NO:REQUEST-METHOD";
            return(pass);
        }
    
        # Mark static files with the X-Static-File header, and remove any cookies
        # X-Static-File is also used in vcl_backend_response to identify static files
        if (req.url ~ "^[^?]*\.(7z|avi|bmp|bz2|css|csv|doc|docx|eot|flac|flv|gif|gz|ico|jpeg|jpg|js|less|mka|mkv|mov|mp3|mp4|mpeg|mpg|odt|ogg|ogm|opus|otf|pdf|png|ppt|pptx|rar|rtf|svg|svgz|swf|tar|tbz|tgz|ttf|txt|txz|wav|webm|webp|woff|woff2|xls|xlsx|xml|xz|zip)(\?.*)?$") {
            set req.http.X-Static-File = "true";
            unset req.http.Cookie;
            return(hash);
        }
    
        # No caching of special URLs, logged in users and some plugins
        if (
            req.http.Cookie ~ "wordpress_(?!test_)[a-zA-Z0-9_]+|wp-postpass|comment_author_[a-zA-Z0-9_]+|woocommerce_cart_hash|woocommerce_items_in_cart|wp_woocommerce_session_[a-zA-Z0-9]+|wordpress_logged_in_|comment_author|PHPSESSID" ||
            req.http.Authorization ||
            req.url ~ "add_to_cart" ||
            req.url ~ "edd_action" ||
            req.url ~ "nocache" ||
            req.url ~ "^/addons" ||
            req.url ~ "^/bb-admin" ||
            req.url ~ "^/bb-login.php" ||
            req.url ~ "^/bb-reset-password.php" ||
            req.url ~ "^/cart" ||
            req.url ~ "^/checkout" ||
            req.url ~ "^/control.php" ||
            req.url ~ "^/login" ||
            req.url ~ "^/logout" ||
            req.url ~ "^/lost-password" ||
            req.url ~ "^/my-account" ||
            req.url ~ "^/product" ||
            req.url ~ "^/register" ||
            req.url ~ "^/register.php" ||
            req.url ~ "^/server-status" ||
            req.url ~ "^/signin" ||
            req.url ~ "^/signup" ||
            req.url ~ "^/stats" ||
            req.url ~ "^/wc-api" ||
            req.url ~ "^/wp-admin" ||
            req.url ~ "^/wp-comments-post.php" ||
            req.url ~ "^/wp-cron.php" ||
            req.url ~ "^/wp-login.php" ||
            req.url ~ "^/wp-activate.php" ||
            req.url ~ "^/wp-mail.php" ||
            req.url ~ "^/wp-login.php" ||
            req.url ~ "^\?add-to-cart=" ||
            req.url ~ "^\?wc-api=" ||
            req.url ~ "^/preview=" ||
            req.url ~ "^/\.well-known/acme-challenge/"
        ) {
                 set req.http.X-Cacheable = "NO:Logged in/Got Sessions";
                 if(req.http.X-Requested-With == "XMLHttpRequest") {
                         set req.http.X-Cacheable = "NO:Ajax";
                 }
            return(pass);
        }
    
        # Cache pages with cookies for non-personalized content
        if (!req.http.Cookie || req.http.Cookie ~ "wmc_ip_info|wmc_current_currency|wmc_current_currency_old") {
            return(hash);  # Cache the response despite cookies
        }
    
        # Remove any cookies left
        unset req.http.Cookie;
        return(hash);
    
    }
    
    sub vcl_hash {
        if(req.http.X-Forwarded-Proto) {
            # Create cache variations depending on the request protocol
            hash_data(req.http.X-Forwarded-Proto);
        }
    }
    
    sub vcl_backend_response {
        # Inject URL & Host header into the object for asynchronous banning purposes
        set beresp.http.x-url = bereq.url;
        set beresp.http.x-host = bereq.http.host;
    
        # If we dont get a Cache-Control header from the backend
        # we default to 1h cache for all objects
        if (!beresp.http.Cache-Control) {
            set beresp.ttl = 1h;
            set beresp.http.X-Cacheable = "YES:Forced";
        }
    
        # If the file is marked as static we cache it for 1 day
        if (bereq.http.X-Static-File == "true") {
            unset beresp.http.Set-Cookie;
            set beresp.http.X-Cacheable = "YES:Forced";
            set beresp.ttl = 1d;
        }
    
            # Remove the Set-Cookie header when a specific Wordfence cookie is set
        if (beresp.http.Set-Cookie ~ "wfvt_|wordfence_verifiedHuman") {
                unset beresp.http.Set-Cookie;
             }
    
        if (beresp.http.Set-Cookie) {
            set beresp.http.X-Cacheable = "NO:Got Cookies";
        } elseif(beresp.http.Cache-Control ~ "private") {
            set beresp.http.X-Cacheable = "NO:Cache-Control=private";
        }
    }
    
    sub vcl_deliver {
        # Debug header
        if(req.http.X-Cacheable) {
            set resp.http.X-Cacheable = req.http.X-Cacheable;
        } elseif(obj.uncacheable) {
            if(!resp.http.X-Cacheable) {
                set resp.http.X-Cacheable = "NO:UNCACHEABLE";
            }
        } elseif(!resp.http.X-Cacheable) {
            set resp.http.X-Cacheable = "YES";
        }
    
        # Cleanup of headers
        unset resp.http.x-url;
        unset resp.http.x-host;
    }
    
    server {
        listen 6216;
        listen [::]:6216;
        server_name localhost;
        {{root}}
      
        try_files $uri $uri/ /index.php?$args;
        index index.php index.html;
      
        location ~ \.php$ {
          include fastcgi_params;
          fastcgi_intercept_errors on;
          fastcgi_index index.php;
          fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
          try_files $uri =404;
          fastcgi_read_timeout 3600;
          fastcgi_send_timeout 3600;
          fastcgi_param HTTPS "on";
          fastcgi_param SERVER_PORT 443;
          fastcgi_pass 127.0.0.1:{{php_fpm_port}};
          fastcgi_param PHP_VALUE "{{php_settings}}";
        }
      
        # Static files handling
        location ~* ^.+\.(css|js|jpg|jpeg|gif|png|ico|gz|svg|svgz|ttf|otf|woff|woff2|eot|mp4|ogg|ogv|webm|webp|zip|swf|map)$ {
          add_header Access-Control-Allow-Origin "*";
          expires max;
          access_log off;
        }
      
        location /.well-known/traffic-advice {
          types { } default_type "application/trafficadvice+json; charset=utf-8";
        }
      
        if (-f $request_filename) {
          break;
        }
      }

    🔁 Replace YOUR_SERVER_IP with your actual public server IP or realip passed by Cloudflare.

    🔁 Add or replace /my-account and others, if it has been translated or changed

    The vcl_recv, vcl_backend_response, vcl_deliver sections include:

    • Cookie & header normalization
    • Full WooCommerce & WordPress compatibility
    • Query string cleaning (especially for tracking)
    • Support for regex-based purging via Varnish HTTP Purge plugin

    👉 Tip: This config already handles X-Forwarded-Proto, removes unneeded cookies, and bans/purges objects precisely.

    2. NGINX Configuration

    You’ll need:

    • One server block to redirect www to non-www
    • One public-facing block that proxies traffic to Varnish (port 6081)
    • One internal block on 127.0.0.1:6216 to serve PHP files
    • Optional: block on port 8080 for wp-admin bypass

    A. Redirect www to non-www

    server {
      listen 80;
      listen 443 ssl http2;
      server_name www.example.com;
      return 301 https://example.com$request_uri;
    }

    B. Public Server Block (Varnish Frontend)

    server {
      listen 80;
      listen 443 ssl http2;
      server_name example.com;
    
      ssl_certificate     /path/to/cert.pem;
      ssl_certificate_key /path/to/key.pem;
    
      real_ip_header CF-Connecting-IP;
    
      location / {
        proxy_pass http://127.0.0.1:6081;  # Varnish listener
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto https;
      }
    
      location ~ \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|mp4|webp|zip)$ {
        expires max;
        access_log off;
      }
    
      location ~ /(wp-login\.php|wp-admin/) {
        proxy_pass http://127.0.0.1:8080;  # Bypass Varnish
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
      }
    }

    C. Internal PHP Handler (for Varnish)

    server {
      listen 6216;
      server_name localhost;
    
      root /var/www/html;
      index index.php index.html;
    
      location / {
        try_files $uri $uri/ /index.php?$args;
      }
    
      location ~ \.php$ {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass 127.0.0.1:9000;  # Adjust to your PHP-FPM port
        fastcgi_read_timeout 300;
      }
    }

    D. Optional: wp-admin Direct Access

    server {
      listen 8080;
      server_name example.com;
    
      root /var/www/html;
      index index.php index.html;
    
      location / {
        try_files $uri $uri/ /index.php?$args;
      }
    
      location ~ \.php$ {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass 127.0.0.1:9000;
      }
    }

    3. System & Service Setup

    Install and Enable Varnish:

    apt/yum install varnish

    Edit systemd file (for port 80 → 6081):

    sudo nano /etc/systemd/system/multi-user.target.wants/varnish.service

    Update ExecStart line:

    ExecStart=/usr/sbin/varnishd -a :6081 -T localhost:6082 -f /etc/varnish/default.vcl -s malloc,512m

    Reload systemd:

    systemctl daemon-reexec
    systemctl restart varnish

    4. Adjust WordPress

    Install plugin for auto-purging:


    5. Testing the Cache

    Run:

    curl -I https://example.com -H "Host: example.com"

    Check headers:

    X-Cacheable: YES

    Use varnishlog to debug:

    varnishlog -g request -q "ReqMethod eq 'GET'"

    What You MUST Change:

    PlaceholderReplace With
    YOUR_SERVER_IPYour public server IP (IPv4)
    example.comYour real domain
    /var/www/htmlYour actual WordPress root path
    127.0.0.1:9000Your PHP-FPM socket or port
    SSL cert pathsUse valid paths to cert.pem and key
    Varnish memory (512m)Adjust according to your RAM

    ⚠️ Notes

    • Ensure that port 6216 and 6081 are not in use by another process.
    • If you’re using Cloudflare, make sure you’re not caching HTML there unless you know what you’re doing.
    • Purge logic depends on the WordPress plugin, or you can implement X-Purge-Method: regex support manually (already included above).
    • Do NOT cache wp-admin or login pages.

    If you need help with load balancing with Varnish or any other advanced configuration, feel free to reach out 🙂