nginx-cache-purge/README.md
James Coleman 8d9b1c9302 Add service management, harden the purge server, and modernize the build.
Server:
- Add a --cache-path allowlist so a server can be limited to the caches it
  is meant to purge, defaulting to any path as before.
- Set socket permissions explicitly (--socket-mode, default 0660) instead of
  inheriting the service manager's umask, which left the socket unreachable.
- Refuse to remove a socket another instance is still serving.
- Read keys both raw and decoded, so keys nginx stored with escapes and keys
  a caller escaped by hand both purge.
- Add an exact= parameter for literal keys containing glob punctuation.
- Report purge failures as 500 rather than 502, and send error bodies through
  http.Error so a failure is not reported as a successful purge.
- Graceful shutdown with systemd readiness notification.

Purge:
- Group purge arguments into PurgeRequest and report the number of entries
  removed.
- Compile exclude globs once, and fail the purge when one is invalid rather
  than purging the keys it was meant to keep.
- Cap header scanning and tolerate entries nginx evicts mid-walk.
- Switch to filepath.WalkDir to avoid an Lstat per cache file.

New:
- service command to install, start, stop, and remove the system service.
- service install takes --cache-path, writing the allowlist into the unit it
  installs, so an installed service is restricted from its first start.
- Makefile, VERSION, and build identifiers stamped via ldflags.
- Tests for the server handler and the service command.

Build:
- Update to Go 1.25, kong v1, GoReleaser v2, and current GitHub Actions.
- Add vet and test steps to CI.
- Rename purgeCmd.go/serverCmd.go to Go's file naming convention.

Bump version to 0.2.0.
2026-08-12 14:58:12 -05:00

8.8 KiB

nginx-cache-purge

A tool to help purge Nginx cache. It can either run locally with the purge command, or run as a local unix service to allow for purging by Nginx http requests. The tool supports using wildcard/glob syntax in the purge key to match multiple keys from the cache.

Install

You can install either by downloading the latest binary release, or by building.

Building

Building should be as simple as running:

make

Usage

The following are some examples of ways to purge cache

Purge a specific key

$ nginx-cache-purge purge /var/nginx/proxy_temp/cache example.com/index.html

Purge all keys for a domain

$ nginx-cache-purge purge /var/nginx/proxy_temp/cache 'example.com/*'

Purge all keys for jpeg and png files

$ nginx-cache-purge purge /var/nginx/proxy_temp/cache 'example.com/*.{jpg,jpeg,png}'

Purge all keys

$ nginx-cache-purge purge /var/nginx/proxy_temp/cache '*'

Purge a key that contains wildcard characters

Whether a key is a wildcard is otherwise guessed from the punctuation in it, and real cache keys carry that punctuation: a request URI with a query string puts ? in the key, and PHP-style array parameters put [ and ]. --exact says the key is a literal, for both the key and any excludes.

$ nginx-cache-purge purge --exact /var/nginx/proxy_temp/cache 'example.com/list.php?f[]=x'

Running as a service

If you want to run as a service to allow purge requests via http requests, the service can install itself:

nginx-cache-purge service install --cache-path /var/nginx/proxy_temp/cache
nginx-cache-purge service start

service also accepts stop, restart, status, and uninstall. The installed unit runs nginx-cache-purge server as a notify service, creates the runtime directory the socket lives in, and restarts on failure. --cache-path restricts which caches that server will purge, and is covered below.

Connecting to a UNIX socket needs write permission on it, so the socket is created mode 0660: the purge server and Nginx have to run as the same user, or share a group. The installed unit runs as root, so either add a User=/Group= drop-in for it, or widen the socket with --socket-mode.

Restricting which caches may be purged

The directory to purge comes from the request, and the purge deletes what it finds under it, so a server left unrestricted will purge any path its user can reach. Pass --cache-path to name the caches it may serve, repeating it for more than one:

nginx-cache-purge server --cache-path /var/nginx/proxy_temp/cache

A request naming any other directory is refused with 403. Directories inside a named cache are allowed, and symlinks are resolved, so a link to a named cache is recognised as that cache. Naming no path leaves every path purgeable, which is what a server without the flag has always done.

service install takes the same flag and writes it into the unit's ExecStart, so an installed service is restricted from its first start:

nginx-cache-purge service install --cache-path /var/nginx/proxy_temp/cache

Repeat the flag for more than one cache. Paths are made absolute at install, as the unit runs from a working directory of the service manager's choosing; symlinks are left as written and resolved per request. The allowlist lives in the unit, so changing it means installing again: service uninstall then service install with the paths you want.

Nginx config

If you want to purge via Nginx http requests, you'll need to add configuration to your Nginx config file.

The server reads four query parameters:

Parameter Description
path Path to the cache directory, the same one given to proxy_cache_path.
key Cache key or wildcard match, the same one built by proxy_cache_key.
exclude Key to keep, can be a wildcard. Repeat it to exclude more than one.
exact Read the key and excludes as literals rather than wildcards.

Nginx substitutes $request_uri into the rewrite without escaping it, so a key containing % or + arrives as the literal bytes Nginx stored it under, and that is what is purged. A key escaped by a caller writing the request itself is purged too, so either convention works.

Literal keys and wildcards

A key built from $request_uri is a literal, and a request URI routinely holds the punctuation that would otherwise mark the key as a wildcard: ? from a query string, [ and ] from PHP-style array parameters. Read as wildcards those keys purge the wrong entries, fail outright, or match nothing while still answering PURGED and leaving the stale entry served. The examples below therefore pass exact=1, which is what you want when a PURGE request names one URL.

Leave exact off where the purge is meant to take a wildcard, such as the /purge(/.*) location further down, where a request for /purge/images/* clears everything under /images/.

Because $request_uri carries the client's own query string into the purge parameters, put exact=1 before key= in the rewrite. Parameters are taken first-wins, so an exact=0 a client appends to its request URI arrives second and is ignored. The same ordering already protects path. Note that exclude is collected rather than taken first-wins, so a client can append an exclude that holds back its own purge.

Map PURGE requests

http {
    map $request_method $is_purge {                                                             
        default   0;
        PURGE     1;
    }

    proxy_cache_path /var/nginx/proxy_temp/cache levels=1:2 keys_zone=my_cache:10m;
    proxy_cache_key $server_name$request_uri;

    server {
        location / {
            proxy_cache_bypass $is_purge;
            if ($is_purge) {
                proxy_pass http://unix:/run/nginx-cache-purge/http.sock;
                rewrite ^ /?path=/var/nginx/proxy_temp/cache&exact=1&key=$server_name$request_uri break;
            }

            proxy_cache my_cache;
            proxy_pass http://upstream;
        }
    }
}
http {
    map $cookie_purge_token $is_purge {
        default 0;
        nnCgKUx1p2bIABXR 1;
    }

    proxy_cache_path /var/nginx/proxy_temp/cache levels=1:2 keys_zone=my_cache:10m;
    proxy_cache_key $server_name$request_uri;

    server {
        location / {
            proxy_cache_bypass $is_purge;
            if ($is_purge) {
                proxy_pass http://unix:/run/nginx-cache-purge/http.sock;
                rewrite ^ /?path=/var/nginx/proxy_temp/cache&exact=1&key=$server_name$request_uri break;
            }

            proxy_cache my_cache;
            proxy_pass http://upstream;
        }
    }
}

Auth via header

http {
    map $http_purge_token $is_purge {
        default 0;
        nnCgKUx1p2bIABXR 1;
    }

    proxy_cache_path /var/nginx/proxy_temp/cache levels=1:2 keys_zone=my_cache:10m;
    proxy_cache_key $server_name$request_uri;

    server {
        location / {
            proxy_cache_bypass $is_purge;
            if ($is_purge) {
                proxy_pass http://unix:/run/nginx-cache-purge/http.sock;
                rewrite ^ /?path=/var/nginx/proxy_temp/cache&exact=1&key=$server_name$request_uri break;
            }

            proxy_cache my_cache;
            proxy_pass http://upstream;
        }
    }
}

Auth via header and IP white list.

http {
    map $http_purge_token $is_purge {
        default 0;
        nnCgKUx1p2bIABXR 1;
    }

    geo $purge_allowed {
        default 0;
        127.0.0.1 1;
        192.168.0.0/24 1;
    }

    proxy_cache_path /var/nginx/proxy_temp/cache levels=1:2 keys_zone=my_cache:10m;
    proxy_cache_key $server_name$request_uri;

    server {
        location / {
            set $should_purge $purge_allowed;
            if ($is_purge != 1) {
                set $should_purge 0;
            }
            proxy_cache_bypass $should_purge;
            if ($should_purge) {
                proxy_pass http://unix:/run/nginx-cache-purge/http.sock;
                rewrite ^ /?path=/var/nginx/proxy_temp/cache&exact=1&key=$server_name$request_uri break;
            }

            proxy_cache my_cache;
            proxy_pass http://upstream;
        }
    }
}

Using IP whitelists

This location takes a wildcard, so it leaves exact off: a request for /purge/images/* clears everything under /images/.

http {
    proxy_cache_path /var/nginx/proxy_temp/cache levels=1:2 keys_zone=my_cache:10m;
    proxy_cache_key $server_name$request_uri;

    server {
        location / {
            proxy_cache my_cache;
            proxy_pass http://upstream;
        }
        location ~ /purge(/.*) {
            allow 127.0.0.1;
            deny all;
            proxy_pass http://unix:/run/nginx-cache-purge/http.sock;
            rewrite ^ /?path=/var/nginx/proxy_temp/cache&key=$server_name$1 break;
        }
    }
}