Category: WordPress

  • 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 🙂

  • Serve WP Rocket Cache Directly via Apache – Rocket-Nginx Style

    Serve WP Rocket Cache Directly via Apache – Rocket-Nginx Style

    If you’re using WP Rocket and want to drastically improve performance, one of the best tricks is to bypass WordPress and PHP entirely when a static cache file exists. While Rocket-Nginx offers this for NGINX servers, Apache users can achieve the same result using smart .htaccess rules.

    Here’s how to do it.


    The Problem

    By default, WP Rocket cache is generated into:

    wp-content/cache/wp-rocket/your-domain.com/
    

    But Apache doesn’t know to check there first. So every request, even if cached, still hits WordPress and PHP — wasting resources.


    The Solution: .htaccess Rewrite Rules

    You can use .htaccess to check if a static cached file exists and serve it immediately, skipping WordPress completely.

    Add this to your root .htaccess file:

    <IfModule mod_mime.c>
      AddEncoding gzip .html_gzip
      AddType text/html .html_gzip
    </IfModule>
    
    <IfModule mod_headers.c>
      <FilesMatch "\.html_gzip$">
        Header set Content-Encoding gzip
        Header set Content-Type "text/html; charset=UTF-8"
      </FilesMatch>
    </IfModule>
    
    <IfModule mod_rewrite.c>
      RewriteEngine On
    
      # Define cache folder
      RewriteCond %{HTTP_HOST} ^(www\.)?(.+)$ [NC]
      RewriteRule .* - [E=HOST:%2]
    
      # Serve gzipped cache if supported and exists
      RewriteCond %{REQUEST_METHOD} GET
      RewriteCond %{HTTP_COOKIE} !(comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_logged_in) [NC]
      RewriteCond %{HTTPS} on
      RewriteCond %{HTTP:Accept-Encoding} gzip
      RewriteCond %{DOCUMENT_ROOT}/wp-content/cache/wp-rocket/%{ENV:HOST}/%{REQUEST_URI}/index-https.html_gzip -f
      RewriteRule .* /wp-content/cache/wp-rocket/%{ENV:HOST}/%{REQUEST_URI}/index-https.html_gzip [L]
    
      # Serve HTTPS non-gzipped cache if exists
      RewriteCond %{HTTPS} on
      RewriteCond %{DOCUMENT_ROOT}/wp-content/cache/wp-rocket/%{ENV:HOST}/%{REQUEST_URI}/index-https.html -f
      RewriteRule .* /wp-content/cache/wp-rocket/%{ENV:HOST}/%{REQUEST_URI}/index-https.html [L]
    
      # Serve HTTP gzipped cache if supported and exists
      RewriteCond %{REQUEST_METHOD} GET
      RewriteCond %{HTTP_COOKIE} !(comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_logged_in) [NC]
      RewriteCond %{HTTPS} off
      RewriteCond %{HTTP:Accept-Encoding} gzip
      RewriteCond %{DOCUMENT_ROOT}/wp-content/cache/wp-rocket/%{ENV:HOST}/%{REQUEST_URI}/index.html_gzip -f
      RewriteRule .* /wp-content/cache/wp-rocket/%{ENV:HOST}/%{REQUEST_URI}/index.html_gzip [L]
    
      # Serve HTTP non-gzipped cache if exists
      RewriteCond %{HTTPS} off
      RewriteCond %{DOCUMENT_ROOT}/wp-content/cache/wp-rocket/%{ENV:HOST}/%{REQUEST_URI}/index.html -f
      RewriteRule .* /wp-content/cache/wp-rocket/%{ENV:HOST}/%{REQUEST_URI}/index.html [L]
    </IfModule>

    Result

    With these rules:

    • Visitors get blazing fast static HTML delivery
    • WordPress and PHP are completely bypassed if cache exists
    • Resource usage drops and TTFB improves dramatically

    This is the closest you can get to Rocket-Nginx behavior on Apache, with zero plugins or server-level modules required.

  • Fixing Elementor Nav Menu Not Working with Lazy Loaded JavaScript (Rocket Loader / WP Rocket)

    Fixing Elementor Nav Menu Not Working with Lazy Loaded JavaScript (Rocket Loader / WP Rocket)

    If you’re using Elementor’s Nav Menu widget and you’ve enabled JavaScript lazy loading via WP Rocket or similar tools (like Cloudflare Rocket Loader), you may run into a frustrating issue:

    Your nav menu appears, but
    Hover effects and hamburger menu don’t work, and dropdowns won’t open.

    This happens because Elementor’s JavaScript — particularly the initialization of the menu widget — gets delayed and doesn’t run when expected.

    Here’s a clean and reliable fix.👇

    Fully Re-initialize All Elementor Widgets After Lazy Load

    <script>
    (function() {
        var maxWaitTime = 5000; // maximum 5 seconds
        var intervalTime = 100; // check every 100ms
        var waited = 0;
    
        var interval = setInterval(function() {
            if (typeof elementorFrontend !== 'undefined' && typeof elementorFrontend.init !== 'undefined') {
                console.log('Elementor frontend detected, full reinit...');
                jQuery.each(elementorFrontend.documentsManager.documents, function(id, document) {
                    if (document && typeof document.container !== 'undefined') {
                        document.container.each(function() {
                            elementorFrontend.elementsHandler.runReadyTrigger(jQuery(this));
                        });
                    }
                });
                clearInterval(interval);
                clearTimeout(timeout);
            } else {
                waited += intervalTime;
            }
        }, intervalTime);
    
        var timeout = setTimeout(function() {
            console.warn('Elementor frontend not ready after 5s, giving up.');
            clearInterval(interval);
        }, maxWaitTime);
    })();
    </script>

    This script:

    • Waits up to 5 seconds for Elementor’s elementorFrontend object to become available.
    • Then manually triggers the initialization of each elementor widget.
    • Prevents duplicate runs with clean-up logic.

    Step 2: Fix Duplicate Arrow Icons in Dropdown Menus

    Some sites end up showing two arrow icons (» » or ▼▼) next to each menu item with a dropdown. This is usually caused by both Elementor and your theme or a plugin (e.g., SmartMenus) injecting arrows.

    Here’s the simple CSS fix:

    .elementor-nav-menu .sub-arrow:nth-of-type(2) {
      display: none !important;
    }

    This ensures only the first arrow icon remains visible, hiding the duplicate.

    Result

    After adding both the script and the optional CSS fix:

    • Your nav menu works on hover.
    • Dropdowns open correctly.
    • Mobile hamburger menu responds after first click.
    • No duplicate arrow icons.
  • Fixing Slow WordPress Database Queries: Missing object_id Index in wp_term_relationships

    If you’re noticing high MySQL/MariaDB load, slow queries, or “Sending data” processes when running WordPress, especially on WooCommerce or sites with many posts/products, you might be hitting this problem.


    Common Symptoms

    When you run SHOW PROCESSLIST;, you might see dozens or hundreds of queries stuck like this:

    SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
    FROM wp_posts
    LEFT JOIN wp_term_relationships ON (wp_posts.ID = wp_term_relationships.object_id)
    WHERE ...
    • Status: Sending data
    • Time: Hundreds or thousands of seconds
    • Command: Query
    • State: Sending data

    This is a clear indicator that WordPress is missing a proper index for efficient joins.


    Root Cause

    By default, WordPress does not create a standalone index on object_id inside the wp_term_relationships table.

    Instead, it only sets a combined primary key:

    PRIMARY KEY (object_id, term_taxonomy_id)

    This works fine when WordPress is looking up by both object_id and term_taxonomy_id, but it is terrible for queries that only join on object_id, which is very common in themes, WooCommerce, custom post queries, and plugins.

    Without a separate index on object_id, MySQL must scan the full table or the combined key every time — leading to massive performance bottlenecks.


    How to Fix It

    You simply need to create a separate index on object_id in the wp_term_relationships table.

    Run this SQL command:

    ALTER TABLE wp_term_relationships ADD INDEX idx_object_id (object_id);

    If this still doesn’t help, in my case it reduced query time but wasn’t enough, add this to your functions.php

    // Globally remove SQL_CALC_FOUND_ROWS from all WPDB queries
    add_filter('query', function($query) {
        if (stripos($query, 'SQL_CALC_FOUND_ROWS') !== false) {
            // You can log output if you want
            // error_log('Intercepted query: ' . $query);
    
            // Remove SQL_CALC_FOUND_ROWS from query
            $query = str_ireplace('SQL_CALC_FOUND_ROWS', '', $query);
        }
        return $query;
    });

    Replace wp_ with your actual WordPress table prefix if it’s different.

    This operation is safe and non-destructive. It will not affect existing data or WordPress functionality. It just improves query speed.


    Immediate Benefits

    • SELECT ... LEFT JOIN wp_term_relationships ON (object_id) becomes instant instead of dragging for minutes or hours.
    • Server CPU and Disk IO usage drops significantly.
    • MariaDB/MySQL stops accumulating hundreds of “Sending data” processes.
    • WordPress pages, product listings, and category queries load much faster.

    Bonus Tip: Avoid SQL_CALC_FOUND_ROWS

    Many slow WordPress queries also use SQL_CALC_FOUND_ROWS, which is deprecated and inefficient.

    Consider refactoring your queries to:

    • Run a SELECT COUNT(*) separately for total counts
    • Use LIMIT and OFFSET without needing FOUND_ROWS()

    This improves performance even further!


    Final Thoughts

    This small database tweak — adding a missing index — can save your server from extreme slowdowns.
    If you’re seeing Sending data issues in SHOW PROCESSLIST;, always check for missing indexes first.


  • Fix Loopback, Action-Scheduler past-due actions and REST API 403 Errors on WordPress Behind Cloudflare

    Fix Loopback, Action-Scheduler past-due actions and REST API 403 Errors on WordPress Behind Cloudflare

    If your WordPress site is behind Cloudflare, you may run into issues where WP-Cron, loopback requests, or the REST API stop working properly. This typically happens because Cloudflare blocks server-to-server requests from your own domain due to bot protection, JavaScript challenges, or rate limiting.


    The Problem

    WordPress internally calls its own URLs (like wp-cron.php or REST API endpoints) using HTTP requests. When your domain is protected by Cloudflare, those internal requests may get blocked with a 403 Forbidden response — even though everything works fine for real visitors.

    This breaks important features like:

    • Cron jobs (DISABLE_WP_CRON or background tasks)
    • Plugin updates and checks
    • Site Health REST API tests
    • Some block editor functionality

    What You See in Site Health

    Under Tools > Site Health, you may encounter this warning:

    The REST API encountered an unexpected result

    The REST API is one way that WordPress and other applications communicate with the server. For example, the block editor screen relies on the REST API to display and save your posts and pages.

    REST API Endpoint: https://your-site.com/wp-json/wp/v2/types/post?context=edit
    REST API Response: (403) Forbidden

    Action Scheduler may show this notice:

    Action Scheduler: 31 past-due actions found; something may be wrong. Read documentation

    You may also see that WP-Cron or plugin update checks silently fail, especially if you’re using Cloudflare Bot Fight Mode or JS Challenge settings.


    The Solution: Bypass DNS and Use Direct IP with Host Header

    To fix this, we can intercept all outgoing HTTP requests from WordPress that go to your-site.com and force them to use the direct IP address, while still sending the proper Host header (your-site.com).

    🛠 Add This Code to Your functions.php

    add_action( 'http_api_curl', function( $handle, $r, $url ) {
        if ( strpos( $url, 'your-site.com' ) !== false ) {
            // Host header
            curl_setopt( $handle, CURLOPT_HTTPHEADER, array(
                'Host: your-site.com'
            ) );
    
            // Override URL to use direct IP
            $ip = '167.253.159.232';
            $new_url = str_replace( 'your-site.com', $ip, $url );
            curl_setopt( $handle, CURLOPT_URL, $new_url );
    
            // Optional: skip SSL verification if HTTPS
            curl_setopt( $handle, CURLOPT_SSL_VERIFYHOST, false );
            curl_setopt( $handle, CURLOPT_SSL_VERIFYPEER, false );
        }
    }, 10, 3 );
    

    🧠 Why It Works

    • WordPress core uses wp_remote_get() and similar functions to communicate with itself.
    • Normally, those requests resolve the domain via DNS — which goes through Cloudflare.
    • With this hook, we force the request to use the real server IP, bypassing Cloudflare.
    • The Host header ensures that WordPress still treats the request as coming to your-site.com.

    Summary

    If you’re getting 403 Forbidden errors when WordPress tries to call itself (for cron jobs, REST API, or updates), and your site is behind Cloudflare, this trick forces internal requests to talk directly to the origin server.

    No more Cloudflare blockages. Just clean internal communication, like it should be.