
nginx-strip-filter-module cut a 79-byte CSS response below a 40-byte cache ceiling, and the next request came back as a cache hit. That is a tiny test, but it illustrates a very grown-up rule: shrink the thing before you cache and compress it, or you are paying to store the mess.
nginx-strip-filter-module is a dynamic NGINX response-body filter for HTML, CSS, JavaScript, JSON, SVG and XML. It removes comments and redundant whitespace while leaving syntax that can change meaning alone. It is not an excuse to run a JavaScript build farm inside your web server. Good. Your workers already have enough ways to make Saturday interesting.
What nginx-strip-filter-module removes
The module is a response filter. Your application, upstream server or FastCGI process sends a normal response. NGINX sees its content type, selects the matching safe compactor, then passes the smaller identity response onward to the compression filters.
For text/css, that means block comments, surplus whitespace, a last semicolon before }, units on a standalone zero such as 0px, leading zeroes in values such as 0.5, and compressible six-digit colours such as #ffaabb. The result is ordinary CSS, just less roomy. It does not wander into url(...) tokens and start “helping” with paths. That is how a clever filter becomes an incident report.
For JavaScript, it removes line and block comments, then collapses whitespace only where doing so preserves the language. Strings, template literals and regular-expression literals pass through. Newlines that matter to JavaScript’s automatic semicolon insertion stay put. A newline after return is not decorative. It is a tiny trapdoor waiting for someone who thinks whitespace is merely a lifestyle choice.
HTML gets comment removal, inter-tag whitespace collapse, safe boolean-attribute shortening and careful attribute unquoting. JSON loses structural whitespace outside quoted strings. SVG and XML lose comments and inter-tag whitespace while keeping CDATA and proper quoted XML attributes. The module decides from the response Content-Type, so a CSS rule does not accidentally receive the HTML treatment because somebody put all the switches in one location block.
Where runtime compaction stops
Words get abused in web performance. A full JavaScript optimiser parses an abstract syntax tree, may rename local identifiers, fold constants and delete unreachable code. CSS tools can rewrite selectors, reorder declarations and apply compatibility transforms. Those jobs belong in an asset build step with tools such as esbuild, Terser or Lightning CSS, where a bad result fails CI rather than quietly upsetting a live NGINX worker.
nginx-strip-filter-module scans syntax closely enough to remove known-safe bytes. It does not tree-shake, mangle names or assemble a heroic 43-step optimiser pipeline from npm packages at 02:13. A response filter should have a small blast radius. That is the whole job description.
Inline <script> and <style> bodies pass through unchanged in HTML mode. Turn on strip_js and strip_css for standalone JavaScript and CSS responses. An HTML page does not become a complete asset compiler because it passed a minifier on the way out.
A sensible configuration for generated assets
Start with a narrow location that receives in-memory bytes from an upstream. Leave the rest of the site alone until you have measured it. The module is off by default, which is the sort of boring default that lets everyone sleep.
load_module modules/ngx_http_strip_filter_module.so;
http {
server {
location /generated-assets/ {
# Ask the upstream for identity bytes. NGINX will compress later.
proxy_set_header Accept-Encoding "";
strip_css on;
strip_js on;
strip_json on;
strip_max_size 1m;
proxy_pass http://app;
}
}
}
That empty Accept-Encoding header is not garnish. If an upstream sends a gzip, Brotli or zstd encoded body, the filter correctly refuses to edit it. Editing compressed bytes would turn a stylesheet into expensive confetti. Let NGINX’s existing gzip, Brotli or zstd filter compress the smaller identity response later; our Zstd, Brotli and zlib-ng comparison explains why that last step still matters.
The module buffers a whole eligible response, so give it an explicit ceiling. The default strip_max_size is 10 MiB, but a generated CSS file that large is already trying to tell you something about the project, and it is not telling you that the day will go well. Pick a cap that matches the route. The filter passes oversized bodies through unchanged.
Static files are a different animal
A normal root-served .css or .js file often travels through NGINX as a file-backed buffer for sendfile. nginx-strip-filter-module intentionally does not pull those buffers into memory and poke at them. cache-turbo makes the same call: it will not capture a file-backed body as if buf->pos contained useful in-memory data.
So do not point both modules at a static asset directory and wait for a miracle. Nothing is broken when that file passes through untouched. It is the safety boundary doing its job.
For files that live in your repository, minify during the build or deployment pipeline, fingerprint the resulting name, then send an immutable cache header. That gets browser and CDN caching working for the asset that probably receives the most traffic. The NGINX filter is for dynamic or upstream-generated content where a build step does not own the final bytes.
Put cache-turbo behind the strip filter
For generated responses, cache-turbo and strip fit together neatly. Load cache-turbo before strip in the NGINX configuration. NGINX registers output filters in last-in, first-out order, so the outbound chain becomes:
strip filter -> cache-turbo -> gzip / Brotli / zstd -> client
Strip sees the upstream body first. cache-turbo stores that smaller identity body. The compression filter runs last and negotiates an encoding for each client. One cached copy can therefore feed a gzip client, a Brotli client or an identity client without replaying the wrong Content-Encoding. Reversing that order is how you get a cache full of the pre-strip body, then spend an afternoon explaining why a cache somehow made storage worse.

load_module modules/ngx_http_cache_turbo_module.so;
load_module modules/ngx_http_strip_filter_module.so;
http {
cache_turbo_zone name=generated_assets 32m;
server {
location /generated-assets/ {
proxy_set_header Accept-Encoding "";
cache_turbo generated_assets;
cache_turbo_valid 30m;
cache_turbo_max_size 512k;
strip_css on;
strip_js on;
proxy_pass http://app;
}
}
}
We tested that exact relationship with a deliberately rude cache limit. The upstream CSS measured 79 bytes. After stripping it fell below a 40-byte cache_turbo_max_size, and the second request returned X-Cache: HIT. That is the proof you want: cache-turbo stored the compact response, not merely a response that happened to look compact after it left the cache.
Keep the cache key honest. If one URL can return a debug version for one request and a stripped version for another, separate the variants in the key or give them different locations. Most deployments avoid the problem by treating minification as a location-level rule: a URL is either served through this pipeline or it is not.
Measure the response you are actually serving
Minification and compression solve related but different problems. A CSS comment is cheap once gzip has chewed on it, but it still costs bytes, cache space and CPU on every cache fill. More importantly, stripping happens before cache-turbo decides whether a body fits cache_turbo_max_size. A response that misses a small in-memory cache by a few kilobytes gets no benefit from a beautiful compression ratio later. It simply goes back to the upstream and asks for another copy, like a junior who deleted the only local branch and is now “checking something”.
Test with identity encoding first, or you are comparing compressed transport bytes instead of the body that strip and cache-turbo see:
curl -sS -H 'Accept-Encoding: identity' \
-D /tmp/headers.txt \
https://example.test/generated-assets/site.css \
-o /tmp/site.css
wc -c /tmp/site.css
grep -Ei 'content-type|content-encoding|x-cache|age' /tmp/headers.txt
Run it twice. The first request fills the cache. A healthy second request shows X-Cache: HIT and an Age header when cache-turbo serves it. If you see a non-identity Content-Encoding from the upstream, stop there. Fix the proxy_set_header Accept-Encoding ""; line before you wonder why the filter appears bored.
Also test the browser behaviour, not only curl. JavaScript syntax bugs love to hide behind a warm browser cache until the awkward moment when somebody opens a private window. Check a page that loads the asset, inspect the console, then reload with cache disabled. This is dull work. So is replacing a production bundle because one newline after return got treated as interior decoration.
Use a location with a deliberate size cap and a known content type. Turning every strip directive on at http {} level before you have representative samples is an attractive shortcut in the same way that changing DNS on Friday afternoon is an attractive shortcut. It saves time right until it does not.
Know the exits before you rely on it
The filter deliberately exits on a few response types. It skips a non-identity Content-Encoding, 206 Partial Content responses, file-backed buffers, and bodies above the configured maximum. It also turns the original strong ETag into a weak one because the body bytes changed. All of those choices are less glamorous than a chart with a rocket ship, but they prevent the sort of corruption that gets blamed on “the network” until somebody finally reads the headers.
Each exit has a very practical reason:
- A compressed upstream body is already an encoded byte stream. It is not CSS or JavaScript any more, however much the response header remembers its earlier life.
- A range response carries byte positions for the original representation. Shrink the body while keeping that
Content-Rangeand a client receives a perfectly confident lie. - A file-backed buffer belongs to sendfile territory. Reading it through an in-memory pointer would be the sort of optimisation that makes sanitizers earn their lunch.
- A changed body cannot honestly keep a strong validator calculated over the old bytes. The filter weakens the ETag rather than pretending the two objects are byte-for-byte twins.
Those are useful checks when you test a route. Try a normal request, then a request with Range: bytes=0-99, then one that asks the upstream for compression. The normal response may strip. The range and pre-compressed responses should pass unchanged. A filter that knows when to do nothing is more trustworthy than one that tries to win every argument with the HTTP specification.
Use it for a CMS that renders a small generated stylesheet, a JSON endpoint with repetitive whitespace, or an application that emits uncompressed JavaScript from an upstream. Use a build tool for versioned static assets. And if you need JavaScript transpilation, bundling or dead-code removal, give that work to the toolchain built for it. NGINX is very good at serving bytes. It does not need to join your frontend stand-up.
Build it, test it, then touch production
The source is available at github.com/eilandert/nginx-strip-filter-module. To build a dynamic module against an NGINX source tree, use the same compatibility mode as the target binary:
./configure --with-compat \
--add-dynamic-module=/path/to/nginx-strip-filter-module
make modules
Run nginx -t before a reload. Then fetch a representative CSS or JavaScript response, inspect its Content-Type and Content-Encoding, and compare the body before and after. The happy path is small. The unhappy path is a one-character syntax change that only appears after a browser cache expires during a customer demo. We have all met that character.
For package context and the rest of the stack, see the NGINX modules repository and the module synopsis. If you are experimenting with JavaScript at the edge instead of merely serving it, our guide to njs and QuickJS-NG is the nearby rabbit hole.
Frequently asked questions
Is nginx-strip-filter-module a real CSS and JavaScript minifier?
Yes, in the safe response-compaction sense: it removes comments and redundant whitespace while preserving strings, templates, regex literals and JavaScript newlines that affect automatic semicolon insertion. It is not an AST optimiser such as Terser or esbuild, so it does not mangle names, bundle files or remove dead code.
Does it minify CSS and JavaScript files served from an NGINX root directory?
Usually no. Normal static files use file-backed sendfile buffers, which the filter deliberately passes through. Build-minify and fingerprint static assets instead; use this module for dynamic or upstream-generated in-memory responses.
Can it minify a response that the upstream already gzipped or Brotli-compressed?
No. The module skips any non-identity Content-Encoding because changing compressed bytes would corrupt the response. Clear the upstream Accept-Encoding header and let NGINX compress after the filter has produced the smaller identity response.
Can cache-turbo cache the minified response?
Yes. Load cache-turbo before strip so NGINX runs strip first, then cache-turbo, then the local compression filter. cache-turbo stores the minified identity body and clients receive their negotiated gzip, Brotli, zstd or identity encoding.
Does it minify inline script and style tags inside HTML?
No. Inline script and style bodies are preserved verbatim in HTML mode. Enable strip_js and strip_css for standalone JavaScript and CSS responses instead.
Related reading
- How to cache pages in NGINX with cache-turbo: the cache layer used in the response order above.
- Zstd vs Brotli vs zlib-ng: the compression decision that comes after minification.
- njs and QuickJS-NG on NGINX: for the moments when serving JavaScript somehow was not enough JavaScript.
- NGINX APT repository for Debian and Ubuntu: the broader module collection.
Trim the bytes you can prove are redundant. Cache the smaller identity response. Compress it for the client. Then go make tea before somebody suggests putting a webpack daemon in the worker process.