log in
consulting hosting industries the daily tools about contact

nginx fastcgi_cache: Purging Without the Paid Module

The paid nginx purge module is fine, but you don't need it. Here's how I handle cache invalidation in production without it.

The nginx Plus purge module is the obvious answer to fastcgi_cache invalidation, and it costs money. The open-source ngx_cache_purge module from FRiCKLE requires recompiling nginx. Neither of those is appealing when you're managing a dozen client servers and want something that survives an apt upgrade. Turns out you don't need either one if you're willing to think about the problem differently.

What fastcgi_cache Actually Does (and Where It Breaks)

fastcgi_cache sits in front of PHP-FPM and stores full HTTP responses on disk, keyed by whatever expression you put in fastcgi_cache_key. Requests that hit the cache never reach PHP. For content-heavy sites - think WooCommerce product pages, real estate listings, healthcare patient-facing portals - this cuts response times from 300-800ms down to single digits. I've seen it drop server load by 60-70% on sites with moderate traffic.

The problem is exactly what you'd expect: when content changes, stale pages sit in the cache until they expire. For a blog with a 60-minute TTL that's annoying but survivable. For an e-commerce site where inventory or pricing changes, or a healthcare portal where document availability matters, it's a real problem.

So you need purging. Here's what actually works.

The Cache Key Is the Whole Game

Before any purging strategy makes sense, your cache key has to be right. Here's what I typically run:

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

Simple. Predictable. The key maps directly to what a user would request. That predictability is what makes software-side purging possible without a dedicated purge endpoint.

I also set up the cache zone in nginx.conf:

fastcgi_cache_path /var/cache/nginx/app
    levels=1:2
    keys_zone=app_cache:10m
    max_size=2g
    inactive=60m
    use_temp_path=off;

Note levels=1:2. nginx hashes the cache key with MD5 and uses the hash to build the directory structure. That hash is what you'll use to find and delete specific files.

Strategy 1: Delete the File Directly

This is the most reliable approach and the one I reach for first. nginx stores each cached response as a file. The file path is derived from the MD5 of the cache key. If you know the key, you can compute the path.

Given the cache key $scheme$request_method$host$request_uri, a GET request for https://example.com/products/widget-pro produces this key string:

httpsGETexample.com/products/widget-pro

MD5 that:

echo -n "httpsGETexample.com/products/widget-pro" | md5sum
# e3b2a7f1c09d4e8b2f6a1d3c7e9f0b4a

With levels=1:2, the file lives at:

/var/cache/nginx/app/a/b4/e3b2a7f1c09d4e8b2f6a1d3c7e9f0b4a

Last character is the first directory, characters 3-4 from the end form the second. Delete that file, the cache is gone for that URL.

In PHP/Laravel I wrap this into a service:

<?php

namespace App\Services;

use Illuminate\Support\Facades\Log;

class NginxCacheService
{
    protected string $cachePath;
    protected string $cacheKey;

    public function __construct()
    {
        $this->cachePath = config('nginx-cache.path', '/var/cache/nginx/app');
        // Must match fastcgi_cache_key in nginx.conf exactly
        $this->cacheKey = config('nginx-cache.key_format', '{scheme}GET{host}{uri}');
    }

    public function purgeUrl(string $url): bool
    {
        $parsed = parse_url($url);
        $scheme = $parsed['scheme'] ?? 'https';
        $host   = $parsed['host'];
        $uri    = $parsed['path'] ?? '/';

        if (!empty($parsed['query'])) {
            $uri .= '?' . $parsed['query'];
        }

        $key  = $scheme . 'GET' . $host . $uri;
        $hash = md5($key);
        $file = $this->buildPath($hash);

        if (!file_exists($file)) {
            return true; // already gone, fine
        }

        $deleted = unlink($file);

        if (!$deleted) {
            Log::error('nginx cache purge failed', ['file' => $file, 'url' => $url]);
        }

        return $deleted;
    }

    public function purgePattern(string $glob): int
    {
        // Nuke everything under the cache path matching a shell glob
        // Use carefully - this walks the whole cache directory
        $count = 0;
        $files = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator(
                $this->cachePath,
                \FilesystemIterator::SKIP_DOTS
            )
        );

        foreach ($files as $file) {
            if (!$file->isFile()) {
                continue;
            }
            // Read the cache file header to find the original URL
            $header = $this->readCacheHeader($file->getPathname());
            if ($header && fnmatch($glob, $header)) {
                unlink($file->getPathname());
                $count++;
            }
        }

        return $count;
    }

    public function purgeAll(): void
    {
        // Brute force, but fast for most cache sizes
        exec('find ' . escapeshellarg($this->cachePath) . ' -type f -delete');
    }

    protected function buildPath(string $hash): string
    {
        // levels=1:2 means last char / 3rd+4th from end / full hash
        $l1 = substr($hash, -1);
        $l2 = substr($hash, -3, 2);
        return $this->cachePath . '/' . $l1 . '/' . $l2 . '/' . $hash;
    }

    protected function readCacheHeader(string $path): string|false
    {
        // nginx cache files start with binary metadata then "KEY: <key>\n"
        $handle = fopen($path, 'rb');
        if (!$handle) {
            return false;
        }

        $header = fread($handle, 4096);
        fclose($handle);

        if (preg_match('/KEY: (.+)\n/', $header, $matches)) {
            return trim($matches[1]);
        }

        return false;
    }
}

Then in a Laravel observer or model event:

// In a Product observer
public function updated(Product $product): void
{
    app(NginxCacheService::class)->purgeUrl(
        route('products.show', $product)
    );
}

This approach requires that the PHP process has filesystem write permissions on the cache directory. I handle that with a shared group between www-data and the nginx worker. chgrp -R www-data /var/cache/nginx/app && chmod -R g+w /var/cache/nginx/app and you're done.

Strategy 2: Stale-While-Revalidate + Short TTLs

For a lot of use cases, the right answer isn't aggressive purging - it's accepting slightly stale content in exchange for simpler infrastructure. nginx supports stale-while-revalidate via the proxy_cache_background_update directive (use the fastcgi equivalent):

fastcgi_cache_use_stale updating error timeout http_500;
fastcgi_cache_background_update on;
fastcgi_cache_lock on;

With a 2-3 minute TTL and background update enabled, users almost never see stale content, and you never need to write purge logic. I use this on content sites and blogs where "2 minutes stale" is totally acceptable. I do not use it on inventory or pricing pages.

Strategy 3: Cache-Control From PHP

You can let PHP control caching behavior per-response. If a page should never be cached - say, a logged-in user's dashboard - set Cache-Control: no-store from Laravel and configure nginx to respect it:

fastcgi_cache_bypass $http_cache_control;
fastcgi_no_cache $http_cache_control;

And in nginx map:

map $http_cookie $no_cache {
    default 0;
    ~XSRF-TOKEN 1;
    ~laravel_session 1;
}

fastcgi_cache_bypass $no_cache;
fastcgi_no_cache     $no_cache;

Sessions mean authenticated users - never cache their responses. This is table stakes and I'm always a little surprised when I find it missing on a server someone else set up.

Gotchas That Bit Me

The cache key must match exactly. I mean character for character. I had a client site where I was computing httpGETexample.com/page but nginx was keying on httpsGETexample.com/page. Purges weren't working. It took longer to find than I'd like to admit. Put add_header X-Cache-Key $upstream_cache_status in your location block during debugging so you can verify what's actually happening.

Query strings. If your cache key includes query strings and you have pagination (?page=2), you'll need to purge each variant separately. I handle this by building a list of URLs to purge rather than trying to do wildcard deletes.

File permissions are silent. If the PHP process can't delete the cache file, unlink() returns false but throws no exception. Log failures. I burned two hours on a staging server where the cache directory was owned by root.

purgeAll() is a blunt instrument. That find -delete command works fine on a 500MB cache. On a 20GB cache it will spike disk I/O and you'll feel it. For large caches I'll queue individual URL purges rather than flushing everything.

The cache file header format. nginx cache files have a binary header before the HTTP response. The KEY: line is there and readable, but the byte offset varies slightly between nginx versions. Reading 4096 bytes and regexing for the pattern has been reliable for me across nginx 1.18-1.26, but it's not a documented API. Test after upgrades.

When I'd Reach for This

I use fastcgi_cache with direct-delete purging on sites that are:

  • WordPress or Laravel, PHP-FPM, moderate traffic (10k-200k pageviews/day)
  • Content changes are event-driven and predictable (product updates, post publishes, record changes)
  • Running on a single server or a small cluster where the cache is local to the web server

I would not use this pattern if:

  • The site runs behind a CDN that also caches (Cloudflare, Fastly) - you'll need their APIs too and the complexity multiplies
  • Multiple web servers share load without shared storage - each server has its own cache, you'd need to purge all of them
  • Real-time accuracy is non-negotiable - for that I'd skip page caching entirely and cache at the query/object layer instead

For the CDN + multiple server scenario I've used Redis-backed cache tags (Laravel's cache tagging with a Redis store) to track which URLs need purging, then hit each server and the CDN API in sequence. That's a post for another day.

The Bottom Line

fastcgi_cache is one of the highest-leverage performance tools I have, and you don't need a paid module or a recompiled binary to make cache invalidation work reliably. Know your cache key, know your file path derivation, and handle purging from application events. It's maybe 100 lines of PHP and it's been running without incident on client servers for years.

Need help shipping something like this? Get in touch.