Zstandard compression for NGINX, written in Rust with ngx-rust and the mzstd encoder.
It is a drop-in replacement for tokers/zstd-nginx-module: the same two modules, directives, defaults and variable, so existing configurations keep working unchanged.
ngx_http_zstd_filter_modulecompresses responses on the fly.ngx_http_zstd_static_moduleserves pre-compressed.zstfiles.
Both modules are built into one dynamic library, so one load_module line loads
both. It has the same extensions as its sibling
ngx_mbrotli: encoders are reused
between responses, and one line, zstd_thread_pool default;, takes compression
out of the worker's event loop and spreads it over the threads of an NGINX
thread pool.
- Example
- Installation from packages
- Building
- Configuration directives
- Variables
- Sample configuration
- Together with ngx_mbrotli
- Differences from tokers/zstd-nginx-module
- Benchmarks
- Implementation notes
- Testing
- Contributing and releases
- License
Compression on a thread pool, so that long responses and high levels do not hold
up the worker's event loop. NGINX must be built with --with-threads:
load_module /path/to/libngx_mzstd.so;
# The pool named `default` exists without being declared: 32 threads per worker
# process, shared with `aio threads`. To size compression on its own, declare
# a pool of its own and name it in `zstd_thread_pool`:
#
# thread_pool zstd threads=4;
http {
zstd on;
zstd_comp_level 3;
zstd_types text/plain text/css application/javascript application/json
image/svg+xml;
zstd_static on;
# Responses are cut into 1m segments; up to 4 of them per response are
# compressed at once on the pool's threads.
zstd_thread_pool default;
zstd_parallel_segment_size 1m;
zstd_parallel_tasks 4;
server {
listen 80;
location / {
root /var/www;
}
# Small API responses: compressing them in the event loop is cheaper.
location /api/ {
proxy_pass http://127.0.0.1:8080;
zstd_thread_pool off;
}
}
}Without zstd_thread_pool the module compresses in the event loop, as
tokers/zstd-nginx-module does. See Configuration directives
for every directive.
Prebuilt packages for amd64 and arm64 are published to Cloudsmith, each built and tested against the exact nginx package it is for. Add the repository, then install the package that matches your nginx:
| Your nginx | Repository | Package | Enabled on install |
|---|---|---|---|
| nginx.org stable | ngx_mzstd |
nginx-module-mzstd |
no |
| nginx.org mainline | ngx_mzstd-mainline |
nginx-module-mzstd |
no |
| Debian's or Ubuntu's own | ngx_mzstd |
libnginx-mod-http-mzstd |
yes |
| RHEL-compatible or Fedora's own | ngx_mzstd |
nginx-mod-mzstd |
yes |
Packages for nginx.org's nginx are built for every distribution release that nginx.org currently packages (Debian, Ubuntu, RHEL 8 to 10 and compatible, Amazon Linux 2023). Packages for the distributions' own nginx are built for Debian, Ubuntu, RHEL-compatible (the default AppStream nginx) and the current Fedora releases, where that nginx is 1.25.4 or newer, the oldest version the module builds against: today Debian 13, Ubuntu 26.04, RHEL 10 and Fedora.
curl -1sLf 'https://dl.cloudsmith.io/public/m-compressors/ngx_mzstd/setup.deb.sh' | sudo -E bashcurl -1sLf 'https://dl.cloudsmith.io/public/m-compressors/ngx_mzstd/setup.rpm.sh' | sudo -E bashcurl -1sLf 'https://dl.cloudsmith.io/public/m-compressors/ngx_mzstd-mainline/setup.deb.sh' | sudo -E bash
curl -1sLf 'https://dl.cloudsmith.io/public/m-compressors/ngx_mzstd-mainline/setup.rpm.sh' | sudo -E bashsudo apt-get install nginx-module-mzstd # Debian, Ubuntu
sudo dnf install nginx-module-mzstd # RHEL-compatible, FedoraLike nginx.org's own nginx-module-* packages, it installs the module without
enabling it. Add this line to the top of /etc/nginx/nginx.conf and reload
nginx:
load_module modules/ngx_mzstd.so;The package requires the exact nginx package it was built for, so upgrade both
together. Its version is <nginx version>+<module version>, as with nginx.org's
module packages.
sudo apt-get install libnginx-mod-http-mzstd # Debian, Ubuntu
sudo dnf install nginx-mod-mzstd # RHEL-compatible, FedoraAs with the distribution's own module packages, the module is enabled on
installation: by /etc/nginx/modules-enabled/50-mod-http-mzstd.conf on Debian
and Ubuntu, and /usr/share/nginx/modules/mod-mzstd.conf on RHEL and Fedora.
Configure zstd (see Configuration directives) and
reload nginx.
The packages conflict with those of tokers/zstd-nginx-module
(nginx-module-zstd, and the distribution's own where it has one), which
provide the same directives. They are built and published by the
Publish workflow.
Package repository hosting is graciously provided by Cloudsmith.
Requirements:
- Rust 1.89 or newer;
- libclang (used by
nginx-systo generate bindings); - the NGINX source tree, configured with the same arguments as the NGINX binary
that will load the module, plus
--with-compat. Runnginx -Vto see those arguments. Only./configureis required, notmake. A freenginx source tree works the same way.
cd nginx-1.30.5
./configure --with-compat <arguments from nginx -V>
cd /path/to/ngx_mzstd
NGINX_BUILD_DIR=/path/to/nginx-1.30.5/objs cargo build --releaseThe module is written to target/release/libngx_mzstd.so
(libngx_mzstd.dylib on macOS). Load it at the top level of nginx.conf:
load_module /path/to/libngx_mzstd.so;The zstd C library is not needed. To build against a vendored NGINX instead,
enable the vendored feature (see nginx-src).
Prefer building against the exact sources and configuration that run in
production.
- syntax:
zstd_static on|off|always - default:
off - context:
http,server,location
Enables or disables checking for pre-compressed files with the .zst extension.
With always, the pre-compressed file is served whether or not the client
supports it. With on and
gzip_vary,
Vary: Accept-Encoding is added whenever the .zst file exists, as with
gzip_static.
- syntax:
zstd on|off - default:
off - context:
http,server,location,if
Enables or disables on-the-fly compression of responses.
- syntax:
zstd_types <mime_type> [..] - default:
text/html - context:
http,server,location
Compresses responses with the listed MIME types as well as text/html, which is
always compressed. The special value * matches any MIME type.
- syntax:
zstd_buffers <number> <size> - default:
32 4k|16 8k - context:
http,server,location
Sets the number and size of the buffers that hold compressed output. The default
is one memory page per buffer, up to 128k in total. It works the same way as
gzip_buffers.
- syntax:
zstd_comp_level <level> - default:
1 - context:
http,server,location
Sets the compression level, from 1 to 22, or a negative level from -1 to
-131072 for faster compression at a lower ratio. Level 0 is not accepted.
Levels 20–22 would need a window larger than 8 MB, which browsers reject; they
are limited by zstd_window.
- syntax:
zstd_window <size> - default:
8m - context:
http,server,location
Sets the largest window, the distance back that matches may reach. Acceptable
values are 1k, 2k, 4k, 8k, 16k, 32k, 64k, 128k, 256k, 512k,
1m, 2m, 4m and 8m. A level whose own window is no larger is not
affected, and when the response length is known, a smaller window is used if it
is enough.
RFC 9659 limits the window of the
zstd content coding to 8 MB, and Chrome rejects responses that need more. With
the default, levels 1–19 compress exactly as without a limit, and levels 20–22
stay within 8 MB.
Not in tokers/zstd-nginx-module.
- syntax:
zstd_min_length <length> - default:
20 - context:
http,server,location
Sets the minimum length of a response that will be compressed. The length comes
only from the Content-Length response header.
- syntax:
zstd_dict_file /path/to/dict | off - default:
off - context:
http,server,location
Compresses responses with the dictionary in the file. A relative path is
taken relative to the directory of nginx.conf. Both kinds of dictionary
that zstd reads are accepted: one in the Zstandard format, such as
zstd --train produces, whose ID each frame names, and raw content, any file
without the dictionary magic number, which the frames do not name.
Only clients that already have the dictionary can decode these responses.
They are sent with Content-Encoding: zstd, as with tokers/zstd-nginx-module,
and nothing in the response tells a client which dictionary it needs; browsers
do not have it and fail to decode them. Use it only for clients you control.
Compression dictionary transport (RFC 9842,
Content-Encoding: dcz) is not supported.
The file is read once, when the configuration is loaded, and a malformed
Zstandard dictionary is rejected there. It is prepared once for each
zstd_comp_level and zstd_window that locations with zstd on use it with,
and the prepared copies are shared by all such locations. With off, a
location inherits no dictionary.
A dictionary helps most with short responses that resemble it, such as small
JSON documents; on long ones the gain is small. Responses compressed with a
dictionary are compressed in the event loop even with zstd_thread_pool:
the parallel compressor does not take dictionaries.
In tokers/zstd-nginx-module it is only allowed in http and has no off.
- syntax:
zstd_reuse_memory on|off - default:
on - context:
http,server,location
Enables or disables reusing encoders between responses. When on, each worker
keeps up to zstd_idle_encoders idle encoders, so a response can reuse the
match finder tables, window and buffers a previous one allocated. How much
memory an idle encoder keeps is set by zstd_retention_policy. When off, every
response gets a new encoder that is freed when the response is complete, as
with tokers/zstd-nginx-module.
Not in tokers/zstd-nginx-module.
- syntax:
zstd_retention_policy aggressive | current_config | release_all | bounded <size> - default:
aggressive - context:
http,server,location
Sets how much working memory an encoder keeps between responses. It matters only
when zstd_reuse_memory is on.
aggressivekeeps every buffer, so a worker allocates only what its largest responses grow into. This is the fastest option, but each worker keeps memory for its largest responses at its highestzstd_comp_level.current_configkeeps buffers only while they suit the current level and window. They are released when the encoder is reused for a location with different settings.bounded <size>keeps buffers while they fit withinsize, and releases them after a response that grew past it.release_allkeeps nothing between responses; only the encoder itself is reused.
Idle encoders are reused only by locations with the same retention policy; when a
worker's zstd_idle_encoders slots are full, the least recently used encoder is
freed.
With zstd_thread_pool, an idle compressor keeps the encoders its segments
used, up to zstd_parallel_tasks of them, and the policy applies to all of them
together. A response holds its compressor until it has been sent, including
while its segments wait for a free thread, so a worker keeps encoders for at
most zstd_parallel_compressors compressors, idle or in use; other responses
allocate encoders for their segments and free them once they have been sent.
An idle compressor is reused only for the same level, window and segment size.
Not in tokers/zstd-nginx-module.
- syntax:
zstd_idle_encoders <number> - default:
4 - context:
http
Sets how many idle encoders each worker keeps for reuse with
zstd_reuse_memory on, from 0 to 1024; when they are all taken, the least
recently used one is freed. Compressors for zstd_thread_pool are kept
separately, up to the same number. With 0 nothing is kept, so every response
gets a new encoder.
Idle encoders are reused only by locations with the same
zstd_retention_policy, so a worker that serves locations with several
policies may need more of them.
Not in tokers/zstd-nginx-module.
- syntax:
zstd_thread_pool <name> | off - default:
off - context:
http,server,location
Compresses responses on the named thread pool instead of in the worker's event
loop, so that a long response or a high zstd_comp_level does not hold up the
worker's other connections. The pool is declared with
thread_pool in the
main context; a pool named default with 32 threads exists without being
declared. Requires NGINX built with --with-threads.
A response is cut into segments of zstd_parallel_segment_size, and up to
zstd_parallel_tasks of them are compressed on the pool's threads at once. They
are sent in order as they complete, while the next segments are read. A response
that fits in one segment is compressed in one piece by one thread. Each segment
is compressed with only a limited overlap of the input before it, so a response
longer than one segment compresses slightly worse, and a flush (for example with
proxy_buffering off) ends the current segment early. The response is still
one standard zstd frame.
Every worker process has its own threads, so worker_processes times the pool's
threads run at once. A response being compressed holds up to
zstd_parallel_tasks + 1 segments of input, their compressed output, and an
encoder for each segment being compressed.
Not in tokers/zstd-nginx-module.
- syntax:
zstd_parallel_segment_size <size> - default:
1m - context:
http,server,location
Sets the size of the segments a response is cut into for zstd_thread_pool,
from 64k to 16m. The larger the segments, the better a response compresses,
but the fewer of its segments are compressed at once. Smaller segments spread
responses over more threads and compress worse.
Not in tokers/zstd-nginx-module.
- syntax:
zstd_parallel_tasks <number> - default:
4 - context:
http,server,location
Sets how many segments of one response may be compressed, or wait to be sent, at
once with zstd_thread_pool, from 1 to 1024. When they all are, NGINX stops
reading the response until the first of them has been sent.
Not in tokers/zstd-nginx-module.
- syntax:
zstd_parallel_compressors <number> - default:
4 - context:
http
Sets for how many compressors used with zstd_thread_pool each worker keeps
segment encoders, from 0 to 1024, counting both idle ones and ones held by
responses. A response holds its compressor until it has been sent, including
while its segments wait for a free thread, so without a limit every response in
flight could keep the encoders a previous response grew. Responses beyond the
limit allocate encoders for their segments and free them once they have been
sent. Idle compressors are also limited by zstd_idle_encoders. Requires NGINX
built with --with-threads.
Not in tokers/zstd-nginx-module.
The achieved compression ratio: the original size divided by the compressed
size, truncated to three decimal places (for example 3.254), as in
tokers/zstd-nginx-module.
load_module /path/to/libngx_mzstd.so;
http {
zstd on;
zstd_comp_level 3;
zstd_static on;
zstd_types application/atom+xml application/javascript application/json application/vnd.api+json application/rss+xml
application/vnd.ms-fontobject application/x-font-opentype application/x-font-truetype
application/x-font-ttf application/x-javascript application/xhtml+xml application/xml
font/eot font/opentype font/otf font/truetype image/svg+xml image/vnd.microsoft.icon
image/x-icon image/x-win-bitmap text/css text/javascript text/plain text/xml;
}As with tokers/zstd-nginx-module, the Vary: Accept-Encoding header is added
only when gzip_vary
is on.
Both libraries can be loaded at once, in either order. When a client accepts
both encodings, zstd is chosen, for .zst files over .br ones and for
compression on the fly; clients that accept only Brotli or gzip get those.
- The window is limited to 8 MB by default (
zstd_window). The C module never declares a response's length, so levels 20–22 declare windows of 32, 64 and 128 MB on every response, however short, and browsers reject them. Here they stay within 8 MB, where long responses compress exactly as at level 19, as they do with the C library given the same window. - The length comes from
Content-Length. When it is known, it is declared to the encoder, which writes it into the frame header and chooses its parameters for that size, so short responses use smaller windows and tables. The C module never declares it, so its output differs on such responses. A response whose body does not match its declared length is aborted. - Accept-Encoding is parsed more strictly.
zstd;q=0is a refusal, and only a wholezstdtoken counts: the C module accepts any header that starts withzstd, such aszstdx. zstd_comp_level -1works. The C module takes -1 for "unset" and compresses at level 1 instead.zstd_dict_filecan be set per server and location, and turned off withoff. A Zstandard-format dictionary is checked when the configuration is loaded.- Encoders are reused by default (
zstd_reuse_memory), each keeping all of its working memory between responses (zstd_retention_policy). Setzstd_reuse_memory offto allocate per response as the C module does. - Responses can be compressed on a thread pool (
zstd_thread_pool), in segments that are compressed in parallel. - The pure-Rust mzstd encoder replaces the C library. Driven with the same calls and parameters, it writes the same bytes as zstd 1.5.7; output compressed on a thread pool is a valid frame but differs.
One NGINX worker compresses static files on the fly for 8 connections at once, in four setups:
- tokers/zstd-nginx-module: as shipped, commit
057a7d3with libzstd 1.5.7. - ngx_mzstd, event loop: ngx_mzstd with its default settings, compressing in the worker's event loop like tokers/zstd-nginx-module.
- ngx_mzstd, pool, 1 task and 4 tasks: ngx_mzstd with
zstd_thread_poolon a pool of 4 threads andzstd_parallel_tasks 1or4(the default), so that one response is compressed by up to 1 or 4 threads at once, in 1m segments.
Only zstd_comp_level changes from row to row. The worker and its pool share 4
CPUs. Each number is the median of three runs under WSL2 (see Hardware, build
and method below): good for comparing the setups with each other, less so as
absolute numbers.
Higher is better. In brackets, relative to tokers/zstd-nginx-module.
| Response | Level | tokers/zstd-nginx-module | ngx_mzstd, event loop | ngx_mzstd, pool, 1 task | ngx_mzstd, pool, 4 tasks |
|---|---|---|---|---|---|
| small.json, 1.9 KB | 3 | 33,243 | 47,777 (1.44×) | 36,934 (1.11×) | 42,453 (1.28×) |
| small.json, 1.9 KB | 9 | 3,968 | 36,116 (9.10×) | 43,643 (11.0×) | 38,706 (9.76×) |
| small.json, 1.9 KB | 15 | 58.4 | 9,130 (156×) | 28,397 (487×) | 28,112 (482×) |
| small.json, 1.9 KB | 19 | 46.0 | 5,070 (110×) | 16,393 (357×) | 16,152 (351×) |
| jquery.min.js, 85 KB | 3 | 3,466 | 4,138 (1.19×) | 12,967 (3.74×) | 12,762 (3.68×) |
| jquery.min.js, 85 KB | 9 | 841 | 855 (1.02×) | 3,312 (3.94×) | 3,298 (3.92×) |
| jquery.min.js, 85 KB | 15 | 50.2 | 200 (3.99×) | 763 (15.2×) | 762 (15.2×) |
| jquery.min.js, 85 KB | 19 | 31.2 | 83.8 (2.68×) | 321 (10.3×) | 323 (10.3×) |
| react-dom.development.js, 1.03 MB | 3 | 359 | 423 (1.18×) | 1,325 (3.69×) | 1,325 (3.69×) |
| react-dom.development.js, 1.03 MB | 9 | 91.3 | 92.9 (1.02×) | 257 (2.81×) | 244 (2.67×) |
| react-dom.development.js, 1.03 MB | 15 | 12.1 | 15.6 (1.29×) | 46.2 (3.82×) | 45.7 (3.77×) |
| react-dom.development.js, 1.03 MB | 19 | 4.99 | 6.34 (1.27×) | 16.3 (3.27×) | 16.0 (3.21×) |
| libraries.json, 2.2 MB | 3 | 175 | 209 (1.19×) | 647 (3.69×) | 665 (3.79×) |
| libraries.json, 2.2 MB | 9 | 48.4 | 49.7 (1.03×) | 118 (2.43×) | 114 (2.35×) |
| libraries.json, 2.2 MB | 15 | 6.99 | 7.74 (1.11×) | 18.5 (2.64×) | 15.5 (2.21×) |
| libraries.json, 2.2 MB | 19 | 2.37 | 2.95 (1.24×) | 6.29 (2.65×) | 6.39 (2.70×) |
The worker's user and system CPU time per request, including the pool's threads, in ms. Lower is better. On the pool, responses longer than one segment (1 MB and more here) cost more, because every segment but the first also runs through part of the input before it.
| Response | Level | tokers/zstd-nginx-module | ngx_mzstd, event loop | ngx_mzstd, pool, 1 task | ngx_mzstd, pool, 4 tasks |
|---|---|---|---|---|---|
| small.json, 1.9 KB | 3 | 0.030 | 0.021 | 0.039 | 0.035 |
| small.json, 1.9 KB | 9 | 0.251 | 0.028 | 0.041 | 0.045 |
| small.json, 1.9 KB | 15 | 17.7 | 0.110 | 0.121 | 0.120 |
| small.json, 1.9 KB | 19 | 21.9 | 0.197 | 0.210 | 0.210 |
| jquery.min.js, 85 KB | 3 | 0.289 | 0.242 | 0.267 | 0.268 |
| jquery.min.js, 85 KB | 9 | 1.19 | 1.17 | 1.18 | 1.18 |
| jquery.min.js, 85 KB | 15 | 20.5 | 5.04 | 5.25 | 5.23 |
| jquery.min.js, 85 KB | 19 | 32.4 | 12.0 | 12.5 | 12.4 |
| react-dom.development.js, 1.03 MB | 3 | 2.78 | 2.37 | 2.90 | 2.93 |
| react-dom.development.js, 1.03 MB | 9 | 10.9 | 10.8 | 15.6 | 16.4 |
| react-dom.development.js, 1.03 MB | 15 | 83.9 | 64.7 | 86.8 | 88.6 |
| react-dom.development.js, 1.03 MB | 19 | 201 | 159 | 246 | 252 |
| libraries.json, 2.2 MB | 3 | 5.71 | 4.78 | 5.79 | 6.01 |
| libraries.json, 2.2 MB | 9 | 20.7 | 20.1 | 34.0 | 35.3 |
| libraries.json, 2.2 MB | 15 | 146 | 130 | 221 | 269 |
| libraries.json, 2.2 MB | 19 | 424 | 342 | 641 | 640 |
The worker's peak resident set size (VmHWM), in MiB. Lower is better. On the
thread pool, every response in flight holds encoders for its segments.
| Response | Level | tokers/zstd-nginx-module | ngx_mzstd, event loop | ngx_mzstd, pool, 1 task | ngx_mzstd, pool, 4 tasks |
|---|---|---|---|---|---|
| small.json, 1.9 KB | 3 | 5 | 5 | 6 | 6 |
| small.json, 1.9 KB | 9 | 14 | 5 | 6 | 6 |
| small.json, 1.9 KB | 15 | 68 | 5 | 7 | 7 |
| small.json, 1.9 KB | 19 | 85 | 5 | 7 | 7 |
| jquery.min.js, 85 KB | 3 | 5 | 6 | 17 | 17 |
| jquery.min.js, 85 KB | 9 | 15 | 6 | 18 | 18 |
| jquery.min.js, 85 KB | 15 | 68 | 8 | 31 | 31 |
| jquery.min.js, 85 KB | 19 | 85 | 8 | 28 | 30 |
| react-dom.development.js, 1.03 MB | 3 | 6 | 7 | 51 | 66 |
| react-dom.development.js, 1.03 MB | 9 | 16 | 16 | 159 | 209 |
| react-dom.development.js, 1.03 MB | 15 | 69 | 38 | 413 | 549 |
| react-dom.development.js, 1.03 MB | 19 | 86 | 39 | 443 | 562 |
| libraries.json, 2.2 MB | 3 | 7 | 8 | 59 | 86 |
| libraries.json, 2.2 MB | 9 | 17 | 17 | 173 | 291 |
| libraries.json, 2.2 MB | 15 | 70 | 71 | 523 | 1,119 |
| libraries.json, 2.2 MB | 19 | 87 | 56 | 505 | 937 |
Hardware, build and method
Hardware
- Intel Core i7-13700KF.
- Ubuntu 22.04.5 under WSL2 2.7.14.0, kernel 6.18.33.2-microsoft-standard-WSL2, on Windows 10.0.26300. WSL2 shows 12 identical cores of 2 threads each and does not expose which of them are performance cores.
Build
- NGINX 1.30.6,
./configure --with-compat --with-threads --without-http_rewrite_module, gcc 11.4.0, both modules dynamic. - tokers/zstd-nginx-module at commit
057a7d3with libzstd 1.5.7. - ngx_mzstd 0.2.3 with mzstd 0.2.9,
cargo build --release(rustc 1.99.0).
Configuration
worker_processes 1, pinned to 4 CPUs on different physical cores, and
thread_pool cp threads=4. On the pool, zstd_thread_pool cp with
zstd_parallel_tasks 1 or 4. Everything else is left at its default, including
zstd_parallel_segment_size 1m, zstd_parallel_compressors 4 and
zstd_retention_policy aggressive. Files are served from tmpfs.
Load
- wrk 4.1.0 over loopback, without TLS, from 4 other physical cores, with 2 threads and 8 connections, for 4 seconds (8 seconds for files over 1 MB, 5 times longer at level 19), after a 1-second warm-up.
- Each run starts NGINX anew and waits until the worker has been idle for 300 ms. Three runs in a shuffled order; the tables show the median.
- CPU time is read from
/proc/<pid>/statof the worker, including the pool's threads, and peak memory fromVmHWMin/proc/<pid>/status. - Every response, for every setup, level and file, was decoded with curl's zstd decoder and compared with the original file by SHA-256.
Files
jQuery 3.7.1 (jquery.min.js) and React DOM 18.3.1
(react-dom.development.js) from cdnjs, the full cdnjs API listing
(libraries.json), and its first 10 entries (small.json), fetched on
2026-10-20. Measured on 2026-11-01.
The filter sits in the same place in the filter chain as tokers/zstd-nginx-module's,
just above the gzip filter. The dynamic library exports an ngx_module_order table
so that NGINX inserts it there.
All access to C bit-fields (request, buffer, variable, location and open-file
flags) goes through src/bitfields.c, compiled against the same headers as
NGINX. The bit-field accessors that bindgen generates for ngx_http_request_t
do not match the C compiler's layout in NGINX 1.30 (they are two bits off from
uri_changes onwards), so they cannot be used safely.
With zstd_thread_pool, each segment becomes an ngx_thread_task_t. Its
completion handler runs in the event loop and calls the zstd filter to send
what is ready, then runs the connection's write handler, as a write event would.
While tasks are posted, the request is blocked (r->main->blocked), so that
terminating it waits for them, and stays buffered, so that it is not finalized
before the last completion handler has run, even when the response has already
been sent.
Unit tests do not need a running NGINX:
NGINX_BUILD_DIR=/path/to/nginx/objs cargo testThe integration tests in t/ use the
nginx-tests framework and need the
zstd command-line tool:
git clone https://github.com/nginx/nginx-tests
TEST_NGINX_BINARY=/path/to/nginx/objs/nginx \
TEST_NGINX_GLOBALS="load_module $PWD/target/release/libngx_mzstd.so;" \
prove -I nginx-tests/lib t/They decode large responses with zstd -M8MB, which rejects a window over 8 MB
as browsers do.
Bug reports and pull requests are welcome. Read CONTRIBUTING.md for the setup and the checks a change must pass, and follow the Code of Conduct. If the module behaves differently from tokers/zstd-nginx-module with the same configuration, open an issue with the Differs from tokers/zstd-nginx-module template. Report security vulnerabilities privately as described in SECURITY.md, not as public issues.
CI (.github/workflows/ci.yml) runs on pull requests and pushes to main:
- rustfmt and clippy;
- unit and integration tests against NGINX 1.28, 1.30 and 1.31 and freenginx
1.31, built with
--with-threads; clippy also checks a build without it; - the integration tests under AddressSanitizer and LeakSanitizer, with NGINX
and the module (including the Rust standard library) instrumented, and
NGX_DEBUG_PALLOCso that each pool allocation is checked on its own; - the unit tests under Miri. The body filter (
src/body.rs) reaches NGINX only through a smallHosttrait, so Miri runs it against a model of NGINX's buffer and chain API (src/body/tests.rs). The model frees drained input buffers and chain links, and leaves output buffers uninitialized, so stale pointers and reads of unwritten memory show up as undefined behaviour. Its thread pool is either a queue that the tests run and report on their own schedule, or real threads, so that Miri also looks for data races between the filter and the segments being encoded.
Changelogs and versions are managed with Sampo.
Every user-facing change needs a changeset, a short Markdown file in
.sampo/changesets/:
cargo install sampo
sampo add # pick the bump level (patch, minor, major) and describe the changeOn every push to main, the release workflow (.github/workflows/release.yml)
collects the pending changesets into a Release (main) pull request that bumps
the version in Cargo.toml and updates CHANGELOG.md. Merging that pull request
tags the release (vX.Y.Z) and publishes a GitHub Release with the changelog
entry. The crate is not published to crates.io.
For the workflow to open pull requests, enable Settings → Actions → General → Allow GitHub Actions to create and approve pull requests.
Packages are published by hand: run the Publish workflow
(.github/workflows/publish.yml) on the release tag. It builds a package for
every target, tests each in a clean container of its distribution (install,
nginx -t, a zstd-compressed response with and without a thread pool,
removal), and pushes them to Cloudsmith only if all of them pass. With
dry_run it builds and tests without pushing, also from a branch. A rerun on
the same tag skips the packages already published. Targets come from nginx.org
and the distributions at run time (.github/scripts/publish-matrix.py, which
also says how to add a distribution); nginx_stable and nginx_mainline
override the nginx.org versions.
MIT; see LICENSE. The directives and their behaviour follow tokers/zstd-nginx-module (BSD 2-Clause).