Skip to content

X-Vlad/nginx-lscache-module

Repository files navigation

nginx lscache module (ngx_http_lscache_module)

LiteSpeed Cache plugin protocol support for nginx. WordPress, Drupal, Magento and any other CMS that ships with an LSCache plugin get full-page caching on nginx without LiteSpeed Web Server.

Native C nginx module. The LiteSpeed Cache plugin (e.g. the WordPress one with 5M+ installs) emits X-LiteSpeed-Cache-Control, X-LiteSpeed-Tag, X-LiteSpeed-Vary and friends on every PHP-FPM response; this module observes them, stores cacheable responses on disk, serves subsequent matching requests via sendfile, and honors out-of-band and inline purges.

Designed for shared hosting providers migrating from Apache+LSCache or LiteSpeed Web Server to nginx, and for any nginx deployment that wants the LSCache plugin family to "just work".

License: BSD-2-Clause nginx Platform Language


Features

  • LiteSpeed Cache plugin protocol on nginx
  • Full-page caching for WordPress, Drupal, Magento, OpenCart, PrestaShop, Joomla
  • X-LiteSpeed-Cache-Control parsing (public, private, max-age, no-cache, no-store, s-maxage, no-vary)
  • X-LiteSpeed-Vary with cookie and value axes
  • X-LiteSpeed-Tag and tag-based purge
  • Out-of-band PURGE method with IPv4 / IPv6 CIDR allowlist
  • Inline X-LiteSpeed-Purge from backend responses
  • LSC-Cookie side channel (stored in cache, replayed as Set-Cookie)
  • Private (per-client) cache slots with _lscache_vary / wp_session / PHPSESSID priority
  • Session-leak guard (Set-Cookie without LSC-Cookie is never stored as public)
  • If-Modified-Since / If-None-Match -> 304 from cache
  • Range request serving from cached entries
  • Size-based eviction (lscache_max_size)
  • lscache_min_uses, lscache_bypass, lscache_no_cache, lscache_strip_cookies
  • Native C nginx module, no Lua, no OpenResty
  • Linux only

Platform: Linux only. The module uses POSIX rename(2) / open(O_EXCL) for atomic cache writes, SHA-1 from the nginx core, and inet_pton / IPv6 helpers from libc.

Why

Nginx has no native equivalent to LiteSpeed Web Server's cache layer. Shared-hosting providers wanting to move off LiteSpeed or Apache hit a wall: their customers' WordPress installs with the LiteSpeed Cache plugin (millions of sites) silently stop benefiting from full-page caching once the front-end becomes nginx, because nginx ignores the plugin's X-LiteSpeed-* headers.

This module fills that gap at the lowest possible overhead: native C, compiled directly into nginx, with sendfile-based serving and atomic-rename-based writes.

Combined with the sibling ngx_http_htaccess_module, nginx becomes a drop-in replacement for Apache+LSCache in shared-hosting deployments.

Protocol Support

Headers consumed from the backend response

Header Status Notes
X-LiteSpeed-Cache-Control Full public, private, max-age=N, s-maxage=N, no-cache, no-store, no-vary, esi (ignored)
X-LiteSpeed-Tag Full Comma list. public: prefix accepted and stripped.
X-LiteSpeed-Vary Full cookie=NAME, value=NAME (resolved against X-LSCache-Vary-NAME request header)
X-LiteSpeed-Purge (response form) Full Triggers an inline purge as a side effect of the request.
LSC-Cookie Full Stored in .meta, replayed as Set-Cookie on every served response.
Set-Cookie Guarded Public response with Set-Cookie is refused (no-cache) unless LSC-Cookie is also present.
Vary (RFC 7234) Guarded Accepted only when the value is empty or just Accept-Encoding. Anything else refuses caching.

Headers emitted to the client

Header When
X-LiteSpeed-Cache: hit,public Served from cache, public entry
X-LiteSpeed-Cache: hit,private Served from cache, private (per-client) entry
X-LiteSpeed-Cache: miss Module is active for this scope; response was generated by upstream
X-LiteSpeed-Cache: no-cache Module wanted to cache but refused (Set-Cookie, Vary, etc.)
X-LiteSpeed-Purge-OK: <N> Response to a successful PURGE request
X-LSCache-Debug-Key, X-LSCache-Debug-Vary Only when lscache_debug on (do not enable in production)

All X-LiteSpeed-* and LSC-Cookie upstream headers are stripped from the client-visible response so that internal protocol state never leaks.

PURGE request syntax

Send the PURGE HTTP method from an IP allowed by lscache_purge_allowed. Body of the X-LiteSpeed-Purge request header:

Header Effect
* Purge all public entries for this Host
private, * Purge all private entries for this Host
url=/foo Purge all variants of /foo on this Host (public)
private, url=/foo Same, private side
tag=t1, tag=t2 Purge entries carrying any of these tags (public)
private, tag=home Same, private side
pub-block ; private-block ; separates blocks (each with its own public,/private, prefix)

stale, ... marks matching entries as needs-refresh (lazy invalidation) instead of unlinking them. The data file stays on disk; the next matching request treats it as a cache miss and the captured upstream response overwrites it. This is non-destructive - if the purge command itself is wrong, the entries will be rebuilt cleanly on first traffic instead of disappearing instantly.

Performance

  • HIT path: open() the cache file, read a 24-byte header, send the body via sendfile() straight from disk. No user-space body buffers, no PHP-FPM round-trip.
  • WRITE path: capture upstream response into a tmp file, atomic rename(2) on completion. Concurrent writers cannot corrupt each other.
  • Size-based eviction runs in-band every ~100 writes per worker. No background thread, no shared memory, no extra processes.
  • Entries are sharded by the first 4 hex chars of the SHA-1 key (two 2-char levels = up to 65 536 leaf dirs), so readdir stays fast at any cache size.

Configuration

Directives

Directive Context Default Description
lscache http, server, location off Enable/disable for this scope
lscache_path <dir> http (required when on) Cache root. Must exist and be writable by the worker user.
lscache_max_size <size> http 1g Soft cap on cache dir size. Eviction runs every ~100 writes.
lscache_methods <method>... http, server, location GET HEAD Methods eligible for caching. POST is opt-in.
lscache_min_uses <n> http, server, location 1 Cache only after the Nth request on the same base key (per worker).
lscache_min_age <seconds> http, server, location 1 Reject max-age lower than this.
lscache_max_age <seconds> http, server, location 86400 Cap max-age to this.
lscache_purge_allowed <CIDR>... http 127.0.0.1 ::1 IPs/CIDRs allowed to send PURGE. Supports IPv4, IPv6, CIDR, all.
lscache_bypass <var> http, server, location none If $var is non-empty/non-zero, skip cache lookup (proxy_cache_bypass parity).
lscache_no_cache <var> http, server, location none If $var is non-empty/non-zero, do not store the response.
lscache_strip_cookies <name>... http, server, location none Cookie names to drop from the cache key (e.g. analytics cookies).
lscache_debug http, server, location off Emit X-LSCache-Debug-Key and X-LSCache-Debug-Vary response headers. Never enable in production.

Variables

Variable Value
$lscache_status HIT / MISS / BYPASS / PURGE / -
$lscache_key 40-char hex SHA-1 cache key, or empty if no key was computed

Minimal configuration

load_module modules/ngx_http_lscache_module.so;

http {
    lscache_path          /var/cache/nginx/lscache;
    lscache_max_size      1g;
    lscache_purge_allowed 127.0.0.1 ::1;

    server {
        listen 80;
        server_name example.com;
        root /var/www/example.com;

        location ~ \.php$ {
            lscache on;
            fastcgi_pass unix:/run/php/php-fpm.sock;
            include      fastcgi_params;
        }
    }
}

The WordPress LiteSpeed Cache plugin (installed at the WP level) then takes over: it emits the protocol headers in its PHP responses, this module observes them, caches accordingly, and the plugin's "Purge All" buttons work automatically because the plugin sends PURGE requests to the same host.

The module auto-advertises itself to the upstream: when lscache on fires for a location, an X-LSCache: on header is injected into the request so fastcgi_pass / proxy_pass forwards it to PHP as HTTP_X_LSCACHE. The LiteSpeed Cache plugin reads that and recognises this nginx as an LSCache-compatible front-end - without it the plugin would silently disable its cache output. No extra fastcgi_param is needed.

Full configuration

load_module modules/ngx_http_lscache_module.so;

http {
    lscache_path          /var/cache/nginx/lscache;
    lscache_max_size      4g;
    lscache_purge_allowed 127.0.0.1 ::1 10.0.0.0/8;

    server {
        listen 80;
        server_name example.com;
        root /var/www/example.com;

        location / {
            lscache             on;
            lscache_methods     GET HEAD;
            lscache_min_uses    1;
            lscache_min_age     1;
            lscache_max_age     3600;
            lscache_strip_cookies _ga _gid _fbp wp_woocommerce_session;
            lscache_bypass      $cookie_nocache;
            lscache_no_cache    $arg_nocache;

            try_files $uri $uri/ /index.php?$args;
        }

        location ~ \.php$ {
            lscache on;
            fastcgi_pass unix:/run/php/php-fpm.sock;
            include      fastcgi_params;
        }
    }
}

Security

  • PURGE is gated by lscache_purge_allowed. Default is 127.0.0.1 ::1 only - without this an attacker could wipe any hosting customer's cache by sending a single HTTP request.
  • A response that carries Set-Cookie and is declared public is refused (X-LiteSpeed-Cache: no-cache) unless an LSC-Cookie is also present. This is the canonical session-leak guard: serving user A's Set-Cookie to user B is a session hijack vector.
  • An upstream LSC-Cookie value containing CR, LF or NUL is treated as a header-injection attempt: the response is refused for caching (X-LiteSpeed-Cache: no-cache) rather than smuggled into a Set-Cookie line on subsequent hits.
  • All X-LiteSpeed-* and LSC-Cookie upstream headers are stripped from the client-visible response. Internal cache state never leaks.
  • Client-supplied X-LSCache request headers are stripped before the module advertises itself to the upstream, so a remote client cannot impersonate an LSCache-compatible front-end to the LiteSpeed Cache plugin.
  • Tag names, host names and url= purge values pass through lsc_safe_name: reject .., \0, any byte < 0x20 or 0x7F, /, \\, leading ., length > 1024. These are the only externally-influenced bytes that flow into a filesystem path component.
  • Inline purges driven by an upstream response (X-LiteSpeed-Purge from a backend) are scoped to the current request's Host. A compromised tenant on a shared cache root cannot wipe other tenants' tagged entries.
  • Cache writes go to <lscache_path>/tmp/<pid>-<seq>-<random> opened with O_CREAT|O_EXCL|O_CLOEXEC and finalize via rename(2). Two workers competing for the same key cannot corrupt each other's tmp file or the final entry.
  • Cache files are created mode 0640 and directories 0750, so other system users on a shared host cannot read in-flight LSC-Cookie material out of <lscache_path>.
  • Response bodies past UINT32_MAX bytes (4 GiB) are refused for caching - the on-disk header records a 32-bit body length, so silent truncation combined with byte-range serving is impossible.
  • RFC 7234 Vary responses with anything beyond Accept-Encoding are refused (cannot be safely shared across clients).
  • X-LiteSpeed-Purge from an upstream response is hard-capped at 64 KB and 64 ;-separated blocks per request to bound worst-case directory walks under a misbehaving backend.

Installation

Quick install (recommended)

git clone https://github.com/X-Vlad/nginx-lscache-module.git
cd nginx-lscache-module
sudo ./install.sh

The installer will:

  • Detect your nginx version (or offer to install nginx from the official repo)
  • Download matching nginx source
  • Build the dynamic module
  • Install .so to the modules directory
  • Add load_module to nginx.conf
./install.sh --check       # check installation status
sudo ./install.sh --uninstall   # remove module

Manual build (dynamic module)

# Download nginx source matching your installed version
nginx -v  # note the version
wget https://nginx.org/download/nginx-X.Y.Z.tar.gz
tar xzf nginx-X.Y.Z.tar.gz
cd nginx-X.Y.Z

./configure --with-compat --add-dynamic-module=/path/to/nginx-lscache-module
make modules

sudo cp objs/ngx_http_lscache_module.so /usr/lib/nginx/modules/

Makefile shortcut

make build NGINX_VERSION=1.30.1     # downloads source, builds .so
sudo make install NGINX_VERSION=1.30.1   # full nginx + module install
make docker-test                     # build the docker image, run all tests

Build as static module

./configure --add-module=/path/to/nginx-lscache-module
make && sudo make install

Requirements

  • Linux (POSIX rename(2), mkdir, opendir)
  • PCRE (libpcre or libpcre2) - inherited from nginx
  • Worker user must own lscache_path and its data, tags, hosts, vary, tmp subdirectories

On Debian/Ubuntu:

apt-get install build-essential libpcre3-dev libpcre2-dev zlib1g-dev libssl-dev

On RHEL/CentOS/AlmaLinux:

yum install gcc make pcre-devel pcre2-devel zlib-devel openssl-devel

Tested nginx versions

The whole t/*.t.sh suite is exercised against each release on every build:

Version Status
1.24.0 green
1.28.2 green
1.30.1 (default in Dockerfile) green

Testing

docker build -t nginx-lscache-test .
docker run --rm nginx-lscache-test                            # all t/*.t.sh
docker run --rm nginx-lscache-test bash /tests/t/stress.sh    # stress harness
make test-versions                                            # whole nginx matrix

t/*.t.sh covers every feature listed above. t/stress.sh drives the module with ab under concurrent PURGE storms and fails the run on any non-zero error rate, worker RSS growth above 8 MB, [alert]/[crit]/[emerg] in the error log, broken liveness probe, or a corrupted cache file.

demo/ boots nginx + PHP-FPM + MySQL + the real WordPress LiteSpeed Cache plugin via Docker Compose; demo/smoke-test.sh asserts miss then hit,public against a sample post end-to-end. See demo/README.md.

License

BSD-2-Clause

About

LiteSpeed Cache plugin protocol for nginx. WordPress / Drupal / Magento full-page caching without LiteSpeed Web Server.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages