zstd-nginx-module: What It Does, Why It Matters, and How We Fixed 22 Bugs in It

What Is Compression and Why Should You Care?

Every time someone visits your website, your server sends files to their browser — HTML, CSS, JavaScript, images. Those files travel across the internet as data, and data takes time to transfer. The bigger the file, the slower the page.

Compression is the trick that makes those files smaller before sending them. Your server squishes the file, sends the smaller version, and the browser unsquishes it on arrival. The user gets the same page, just faster. Much faster. A typical HTML or CSS file can shrink by 70–80% with modern compression.

You’ve probably heard of gzip — it’s been the standard for web compression for over 20 years. There’s also Brotli, Google’s compression algorithm from 2015. But today we’re talking about the newcomer that’s beating both of them: Zstandard, or zstd.


What Is Zstd and Why Is It Better?

Zstandard (zstd) was developed by Yann Collet at Facebook and open-sourced in 2016. It was built with one specific goal: be fast. Not just decent compression with reasonable speed — genuinely, embarrassingly fast. Fast enough that the CPU overhead of compressing on-the-fly becomes negligible even on busy servers.

Here’s how it compares to the alternatives:

  • gzip — Old reliable. Good compression, slow speed, supported everywhere.
  • Brotli — Better compression than gzip, but slow at higher levels. Great for pre-compressed static files.
  • zstd — Comparable or better compression than gzip, at 3–5× the speed. Excellent for on-the-fly compression of dynamic responses (API output, PHP pages, database results).

For a busy web server generating dynamic pages, zstd is the clear winner. Your server compresses faster, sends less data, and users see pages load quicker. Everyone wins — including your hosting bill.


What Is the zstd-nginx-module?

NGINX doesn’t have zstd support built in. To add it, you need a module — a plugin that extends what NGINX can do. The zstd-nginx-module, originally written by tokers, adds two things:

  • Dynamic compression — Compresses responses on the fly as NGINX generates them. Perfect for PHP pages, API responses, and anything else generated at request time.
  • Static compression — Serves pre-compressed .zst files. You compress your CSS and JS files once at deploy time, and NGINX serves the pre-compressed version directly. No CPU overhead at all per request.

We include this module in our deb.myguard.nl repository for Debian and Ubuntu. Before shipping anything, we do what any responsible maintainer should do: we read the code.

What we found was not great.


22 Bugs. Here’s What They Were.

We found 22 bugs — ranging from “mildly annoying” to “this will crash your server.” Here they are, explained in plain language, sorted from scariest to least scary.

Bug 1 — The Buffer Overflow That Could Crash Your Server CRITICAL

Think of a buffer as a cup. You need to pour 5 ml of water into it. The cup holds 3 ml. What happens? It overflows.

The static module builds a file path by taking the URL the browser requested and appending .zst to it. The code reserved 3 bytes of space for .zst — but .zst is actually 4 characters plus a null terminator, so 5 bytes. The code was writing 5 bytes into a 3-byte cup, every single time a visitor requested a static file.

The overflow overwrites whatever memory happens to live next door. On a web server, that memory is request state — open connections, headers, security context. Depending on the server and load, this could silently corrupt data, cause random crashes, or in adversarial scenarios, be exploitable. We bumped the allocation to the correct size. Fixed.

Bug 2 — Silent Data Truncation at Exactly 128 KB CRITICAL

This one is particularly sneaky because it gives no error. Absolutely none.

Compress any response larger than 128 KB (131,072 bytes), and the client receives exactly 128 KB back. The rest is silently dropped. No error log. No failed status code. The server returns HTTP 200 OK and serves a broken, incomplete file.

Why? Deep inside the compression state machine, when the compression buffer fills up, the code flipped a flag that said “we’re done” — even when there was still more data to compress. The zstd frame ended prematurely. A 141 KB JavaScript file came out as 128 KB, every time, reproducibly, silently. The browser would decompress what it received and try to execute a partial script. You’d see broken pages. Logs would show clean 200s. It would be nearly impossible to diagnose.

We fixed the state machine so it only declares “done” when the input buffer is actually empty.

Bug 3 — Memory Pool Overflow from a Variable CRITICAL

The module exposes a $zstd_ratio variable you can log — it shows the compression ratio for each response. The code allocated 13 bytes of memory to store this ratio, then tried to write a value that could need up to 44 bytes (two 64-bit numbers, a decimal point, null terminator). On every request where this variable was used, the code was writing 44 bytes into a 13-byte space and overwriting NGINX’s internal memory pool. This is the kind of bug that causes mysterious crashes hours later, not immediately, making it very hard to track down. We increased the allocation to the correct size.

Bug 4 — A NULL Dereference in the Output Buffer Path CRITICAL

A NULL pointer dereference is programming shorthand for “the code tried to use something that doesn’t exist.” When an NGINX worker process does this, it crashes immediately. Crashes mean dropped connections — every visitor being served by that worker gets a broken page until NGINX restarts it. The output buffer code assumed its allocation always succeeded and immediately used the result without checking. We added an explicit check.

Bug 5 — The Header Parser Matched 3 Letters Instead of 4 HIGH

When a browser sends a request, it includes an Accept-Encoding header saying which compression algorithms it supports. The server is supposed to check this before compressing. The module was checking for the string zstd using a length of 3 — so it was actually matching zst. Any client whose header happened to contain zst anywhere — even as part of another word — would be served zstd-compressed responses it never asked for and might not be able to decode. Off by one character. Fixed.

Bug 6 — A Compiler Flag That Contaminated Every Other Module HIGH

During the NGINX build process, the zstd static module set a global compiler flag (-DZSTD_STATIC_LINKING_ONLY) that was then applied to every other module compiled in the same run. ModSecurity, GeoIP2, Brotli — all of them got an unexpected define they weren’t designed for. The results are unpredictable: silent miscompilation, subtle behavioral differences, or outright build failures. We removed the global assignment.

Bug 7 — A Linker Flag That Only Worked on Linux HIGH

The build script used GNU-specific linker syntax (-l:libzstd.a) that doesn’t work on FreeBSD, OpenBSD, or RHEL 9+. There was also a stray space in an rpath flag that split one argument into two, silently breaking dynamic library loading on every platform. We replaced the whole approach with standard pkg-config detection — the right way to find libraries on any Unix system.

Bug 8 — An API Called Without Checking the Library Version HIGH

ZSTD_minCLevel() — a function used to clamp compression levels — was introduced in zstd 1.4.0. The code called it unconditionally. On RHEL 7 (zstd 1.3.3) or older FreeBSD, compilation fails with a mysterious “undefined symbol” error. We wrapped the call in a version check with a safe fallback.

Bug 9 — Missing Vary Header Breaks Your CDN HIGH

When a server sends compressed responses, it must include a Vary: Accept-Encoding header. This tells CDNs and proxies: “I serve different content to different clients depending on what they support — cache them separately.” Without this header, a CDN caches the first response it receives (say, zstd-compressed) and serves it to everyone, including browsers that don’t speak zstd. Those browsers receive a compressed file they can’t decode. Error. Broken page. And your CDN serves it with a confident 200 OK.

The module relied on gzip_vary on being set in config but never warned you if it wasn’t. We added a startup warning: if you enable zstd without gzip_vary on, NGINX now tells you at launch instead of letting you discover it after your CDN poisons its cache for thousands of users.

Bug 10 — Only 3 HTTP Status Codes Got Compression MEDIUM

The filter module only compressed responses with HTTP status 200, 403, and 404. Every other status — 201 Created, 206 Partial Content, 202 Accepted, etc. — was passed through uncompressed regardless of size. A REST API returning 201 with a large JSON payload got zero compression benefit. We expanded the check to cover all 2xx responses.

Bug 11 — Dictionary Loaded as NULL When Parent Was Disabled MEDIUM

zstd supports compression dictionaries — pre-trained data structures that dramatically improve compression on repetitive content (like similar JSON API responses). The module has a shortcut: if a parent location and child location share the same compression level, reuse the parent’s dictionary. But when the parent had compression disabled, its dictionary was NULL. The shortcut handed NULL to the child, which then claimed to be serving dictionary-compressed responses without any dictionary at all. Garbage output. We added a null check.

Bug 12 — The Quality-Value Parser That Accepted Anything MEDIUM

HTTP lets clients specify a preference for compression algorithms using values like zstd;q=0.9. The q value must be between 0 and 1. The parser was accepting q=999, q=0., and q=1.5 as valid, which meant any client sending a malformed header could trigger zstd compression even if their implementation is broken. We rewrote the parser to strictly follow the RFC 7231 grammar.

Bug 13 — Two Identical Functions, One in Each Module MEDIUM

The filter module and static module both contained byte-for-byte identical copies of two complex functions (80+ lines of RFC-correct Accept-Encoding parsing). Two copies of the same code means any future bug fix has to be applied twice — and someone will forget. We extracted both into a shared header file included by both modules.

Bug 14 — Calling Free on a Pointer Never Initialized MEDIUM

On compression failure, the cleanup code called ZSTD_freeCStream(ctx->cstream). If the failure happened before cstream was ever created, this freed a NULL pointer. The zstd library handles NULL gracefully, but this is an undocumented dependency — future zstd versions aren’t guaranteed to keep that behavior. We added an explicit NULL guard.

Bug 15 — The Documentation Told You to Do the Wrong Thing MEDIUM

The README recommended static linking (libzstd.a) over dynamic linking. This is backwards — NGINX dynamic modules are .so files that cannot statically link a non-position-independent archive. Following this advice produces a linker error. We replaced the guidance with the correct instruction: install libzstd-dev on Debian/Ubuntu (or libzstd-devel on RHEL), and let the build scripts handle the rest.

Bugs 16–22 — Lower Severity Issues

The remaining bugs were lower severity but still worth fixing:

  • Content-Type manipulation was a no-op — The static module modified a path variable that the Content-Type function doesn’t even look at. Four lines of pointless code removed.
  • Default compression level too conservative — The default was level 1 (fastest, worst ratio). The zstd library’s own default is level 3. We changed it to match upstream.
  • ETag weakening was undocumented — When compressing a response, NGINX converts strong ETags to weak ones (per RFC 7232). This has implications for CDN caching. Now documented.
  • zstd_max_length doesn’t apply to chunked responses — If a response has no Content-Length header, the size limit is ignored. Now documented.
  • No warning when building against an old zstd — The build succeeds silently on old zstd versions but uses deprecated fallback APIs. The configure script now warns you.
  • RPATH baked in at build time — If you use a custom library path and later move the library, the module fails to load. Now documented so operators know what they’re signing up for.
  • Compression ratio overflow on files over 4 GB — The ratio calculation used 32-bit arithmetic that wraps around on large files. Promoted to 64-bit.

4 Performance Optimizations We Made While We Were In There

Finding bugs is one thing. But while reading every line of code, we also found four places where the module was wasting significant resources on every single request. We fixed those too.

Optimization 1 — Stop Allocating 100 KB of Memory Per Request

The original code created a brand new compression context — roughly 100 KB of internal state — at the start of every compressed request, then freed it at the end. On a busy server handling thousands of requests per second, this means thousands of 100 KB allocations and deallocations per second, which forces the operating system to constantly map and unmap memory pages.

NGINX’s own gzip module has never worked this way. It keeps one compression context per worker process and reuses it. We made zstd work the same way: allocate once at worker startup, reset between requests, free at worker shutdown. The per-request allocation is gone entirely.

Optimization 2 — Use the Modern Unified API

The streaming compression loop used three separate functions — compress, flush, end — dispatched through a state machine. Since zstd 1.4.0, all three are unified in a single function called ZSTD_compressStream2. We replaced the three-way dispatch with one call. Simpler code, fewer branches, same result.

Optimization 3 — Replace the Deprecated Initialization API

The context initialization was using a function deprecated since zstd 1.5.0. We switched to the modern replacement, which has the added benefit of making it easy to set additional per-location parameters in the future — things like checksum flags or async multithreaded compression — without changing the init call.

Optimization 4 — Remove the Custom Allocator Routed Through NGINX’s Pool

The original code routed all of zstd’s internal memory allocations through NGINX’s per-request memory pool. This sounds clever — NGINX pools are fast and automatically cleaned up. But NGINX pools don’t support mid-request frees. When zstd internally frees and reallocates working memory (which it does), the free calls were silent no-ops, and every reallocation added more permanent pool memory that was never reclaimed until the request ended. We let zstd use the system allocator for its internal state — which it manages correctly — and reserved the NGINX pool for per-request output buffers only, where it belongs.


The CI Pipeline We Built to Keep It Fixed

After 22 bugs across multiple passes, we wanted a system that would catch regressions automatically. We set up a four-stage GitHub Actions pipeline on our fork:

  • Validation — shellcheck on build scripts, cppcheck static analysis, clang static analyzer.
  • Build — Compiles NGINX from source with the module, using -Wall -Wextra -Wshadow -Werror. A compiler warning is a build failure.
  • Tests — 41 tests covering the filter module (23 tests) and static module (18 tests), including edge cases for every bug we fixed and full RFC quality-value parsing coverage.
  • Security scan — flawfinder, clang-tidy with cert and bugprone checks, and a semgrep scan.

Every push and every pull request runs all four stages. The idea is simple: static analyzers catch some things, a strict compiler catches others, and only actual runtime tests catch the rest. You need all three layers.


How to Install the Fixed Module

Our fixed fork is packaged and available in the myguard repository for Debian and Ubuntu. If you’re already using the repository, it’s a single command:

apt update
apt install libnginx-mod-http-zstd
# or for Angie:
apt install libangie-mod-http-zstd

Then add to your NGINX config:

gzip_vary on;       # required — tells CDNs to cache compressed and uncompressed separately

zstd on;
zstd_comp_level 3;  # default; 1 = fastest, 22 = best compression
zstd_types text/plain text/css application/javascript application/json application/xml;

Reload NGINX:

nginx -t && systemctl reload nginx

Verify a client that accepts zstd gets a compressed response:

curl -H "Accept-Encoding: zstd" -I https://yoursite.com
# Look for: Content-Encoding: zstd

The fork is also publicly available at github.com/eilandert/zstd-nginx-module if you build from source.


Why Does This Matter?

You might be wondering: if these bugs existed, wouldn’t everyone running this module be having obvious problems?

That’s exactly the danger of silent bugs. The truncation bug — serving 128 KB instead of 141 KB — doesn’t throw an error. The browser decompresses what it got, tries to execute a partial JavaScript file, and something subtle breaks. Users see a broken menu, a form that doesn’t submit, a page that loads but doesn’t work quite right. The logs show a clean HTTP 200. Support tickets say “website is broken sometimes.” These are the hardest bugs to diagnose.

The buffer overflow is even worse. It doesn’t crash immediately. It corrupts memory slowly. The crash might happen ten seconds later, or ten minutes, or during the next high-traffic spike when a specific memory layout triggers it. By the time something breaks, the original cause is long gone from the logs.

This is why code review matters. Not because the original authors were careless — they weren’t — but because complex C code is genuinely hard to get right, and a second set of eyes with different assumptions catches different things.


Frequently Asked Questions

What is zstd compression and do I need it?

zstd (Zstandard) is a modern compression algorithm that’s significantly faster than gzip at comparable compression ratios. If you run a website with dynamic content — WordPress, APIs, any PHP application — enabling zstd compression will make your pages load faster with less CPU overhead than gzip. Most modern browsers support it. Yes, you should use it.

Do these bugs affect me if I’m using the original module?

Yes. If you’re using the upstream tokers/zstd-nginx-module without our patches, the buffer overflow and silent truncation bugs are active. The truncation bug will silently serve broken files for any response over 128 KB. We strongly recommend switching to our fork or the packages from deb.myguard.nl.

Is zstd compatible with all browsers?

Chrome has supported zstd since version 118 (late 2023). Firefox added support in version 126 (2024). Safari is still catching up as of 2026. NGINX always falls back to gzip or no compression for browsers that don’t support zstd — it never forces zstd on a client that doesn’t ask for it. So enabling zstd is safe: modern browsers benefit, older ones get gzip as always.

What’s the difference between dynamic and static zstd compression?

Dynamic compression compresses responses on the fly as NGINX serves them. Every request that matches your zstd_types list gets compressed in real time. Some CPU overhead per request, but works with any content including generated pages. Static compression serves pre-compressed .zst files you prepared in advance — zero CPU overhead per request, ideal for CSS, JS, and HTML that doesn’t change often.

Does enabling zstd replace gzip, or do I need both?

You need both. Keep gzip on for clients that don’t support zstd (older browsers, some HTTP clients). Enable zstd on for clients that do. NGINX uses the Accept-Encoding header to serve the right format to each client automatically. gzip_vary on ensures CDNs cache both variants correctly.

What compression level should I use?

For dynamic on-the-fly compression, level 3 (the new default in our fork) is a good starting point — it matches zstd’s own library default and provides a good balance of speed and ratio. For static pre-compressed files where CPU isn’t a concern, levels 9–15 give noticeably better ratios. Level 22 is the maximum — use it for pre-compressing large static assets where every byte counts.

Why does NGINX need a module for zstd? Isn’t it built in?

gzip is built into NGINX core. Brotli and zstd are third-party modules. The official NGINX project has been slow to add new compression algorithms to core. In practice, third-party modules like this one are how the community gets modern features before they make it upstream. Our packages at deb.myguard.nl include the fixed module pre-compiled for Debian and Ubuntu so you don’t have to build anything yourself.


Related Posts

If this article was useful, these posts go deeper into the stack that zstd lives in:


Our fixed fork of the zstd-nginx-module is at github.com/eilandert/zstd-nginx-module. Pre-built Debian and Ubuntu packages are available through the myguard repository. Found something we missed? Open an issue or leave a comment below.