# M-Compressors: full text > Engineering blog of M-Compressors: Brotli and Zstandard rewritten in safe Rust, byte-identical to the C reference encoders, plus NGINX modules that compress on thread pools. Benchmarks, design notes and release write-ups. Source: https://m-compressors.github.io. Author: Mikhail Panfilov (https://m-compressors.github.io/authors/mikhail-panfilov/). Quote freely with a link to the post's URL. ## Projects ### mbrotli Rust crate, version 0.5.16, license MIT OR Apache-2.0. Google Brotli in safe Rust: encoder ported from the C reference with the same output at qualities 0–11, a native decoder, Read/Write adapters and parallel compression on your own threads. Repository: https://github.com/M-Compressors/mbrotli Crate: https://crates.io/crates/mbrotli API docs: https://docs.rs/mbrotli ### mzstd Rust crate, version 0.2.8, license MIT OR Apache-2.0. Zstandard (RFC 8878) in safe Rust: single-threaded output byte-identical to zstd 1.5.7, a decoder written from the specification, no_std and a C ABI. Repository: https://github.com/M-Compressors/mzstd Crate: https://crates.io/crates/mzstd API docs: https://docs.rs/mzstd ### ngx_mbrotli NGINX module, version 0.4.5, license MIT. Drop-in replacement for google/ngx_brotli that can compress on NGINX thread pools, so large responses no longer block the worker's event loop. Repository: https://github.com/M-Compressors/ngx_mbrotli ### ngx_mzstd NGINX module, version 0.2.3, license MIT. Drop-in replacement for tokers/zstd-nginx-module with encoder reuse and thread-pool compression. Repository: https://github.com/M-Compressors/ngx_mzstd ### borrowed-buf Rust crate, version 0.1.1, license MIT. A small no_std port of the nightly-only core::io::BorrowedBuf and BorrowedCursor for stable Rust 1.89. Repository: https://github.com/M-Compressors/borrowed-buf Crate: https://crates.io/crates/borrowed-buf --- ## zstd on NGINX thread pools: ngx_mzstd vs zstd-nginx-module URL: https://m-compressors.github.io/2026/ngx-mzstd-thread-pool-benchmarks/ Published: 2026-10-22 On-the-fly zstd blocks the NGINX event loop. ngx_mzstd on a thread pool against tokers/zstd-nginx-module: p99 latency, requests per second, CPU time and memory. Summary: - With tokers/zstd-nginx-module, zstd compression runs inside the NGINX worker’s event loop. While large responses were compressed at levels 3 and 19, the p99 latency of small uncompressed requests on the same worker rose from 1.9 ms to 4.2–198 ms, and to 1.5 s when 8 large requests arrived at once. - ngx_mzstd with zstd_thread_pool compresses on NGINX thread pools. In the same test, the p99 of small requests stayed at 1.7–2.0 ms, the same as with no compression. - One NGINX worker with a 4-thread pool served 2.2–15 times as many zstd responses per second as tokers/zstd-nginx-module on 85 KB–2.2 MB files, the most at levels 15 and 19. - With the default 1m segments, the pool used 1.4–2.1 times the CPU time per response of the event loop at levels 9–19. Two causes were measured: segment overlap and encoders allocated anew. zstd_parallel_segment_size 4m brought it down to 1.04–1.21 times in all but one case. - Measured on 20 October 2026 with NGINX 1.30.6, one worker, an Intel Core i7-13700KF under WSL2. Every response was decoded and checked against the original by SHA-256. **ngx_mzstd** is an NGINX module for on-the-fly Zstandard (zstd) compression, written in Rust on top of the [mzstd](https://github.com/M-Compressors/mzstd) encoder. It is a drop-in replacement for [tokers/zstd-nginx-module](https://github.com/tokers/zstd-nginx-module), with the same directives, and needs no libzstd. Unlike tokers/zstd-nginx-module, it can compress on NGINX thread pools, so that compressing a large response does not stop the worker from serving anything else. This post measures what that changes, and what it costs. One NGINX worker served large files compressed on the fly while a stream of small uncompressed requests went to the same worker. With tokers/zstd-nginx-module at level 19, the 99th percentile latency of the small requests rose from 1.9 ms to 198 ms, and to 1.5 s when 8 large requests arrived together. With ngx_mzstd on a 4-thread pool it stayed at 1.7–2.0 ms. The numbers come from a reduced run under WSL2 on 20 October 2026. They are good for comparing the modules with each other, less so as absolute figures for your hardware. [How it was measured](#how-it-was-measured) has the details and what was not measured. ## Why compression delays other requests An NGINX worker process serves all of its connections from one thread, in an event loop that runs a short piece of work for each ready socket. A filter that runs for 200 ms holds every other connection of that worker for 200 ms. tokers/zstd-nginx-module compresses in the event loop, as the stock gzip module does. zstd at level 3 is fast: 2.8 ms of CPU time for a 1 MB JavaScript bundle. At level 19 the same file takes about 200 ms. A small request that arrives on that worker meanwhile waits for compression to end. When 8 large requests arrive together, the worker compresses them one after another, and the small request waits about 8 × 200 ms. NGINX has had thread pools since version 1.7.11, released on 24 March 2015 ([CHANGES](https://nginx.org/en/CHANGES)), and uses them for blocking file I/O (`aio threads`). ngx_mzstd uses them for compression. ## How ngx_mzstd compresses on a thread pool With `zstd_thread_pool`, the module cuts a response into segments of `zstd_parallel_segment_size`, 1 MiB by default. Each segment becomes an NGINX thread task and is compressed on one of the pool's threads; up to `zstd_parallel_tasks` segments of one response, 4 by default, are compressed at once. The completion handler runs in the event loop, sends the compressed bytes in order and wakes the connection. Meanwhile the event loop serves other connections. The response is still one standard zstd frame. Unlike Brotli segments in [ngx_mbrotli](/2026/ngx-mbrotli-thread-pool-benchmarks/), zstd segments are not fully independent. Every segment but the first starts by running through the end of the input before it, so that its matches can reach back across the boundary. That keeps the compression ratio close to the event loop's, and it costs CPU time, as shown [below](#where-the-extra-cpu-time-goes). ```nginx load_module modules/ngx_mzstd.so; 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_thread_pool zstd; zstd_parallel_segment_size 4m; } ``` NGINX must be built with `--with-threads`, as nginx.org's packages are. The pool named `default` exists without being declared, with 32 threads per worker, and is shared with `aio threads`. The reason for `4m` is in [How to configure it](#how-to-configure-it). ## How long do other requests wait? Small uncompressed requests (`small.json`, 1.9 KB) were sent at 1,000 per second over 32 connections. A second stream requested `react-dom.development.js` (1.03 MB) compressed, at a constant rate of about half of what tokers/zstd-nginx-module can sustain. All variants kept up with both streams. Numbers as a table: | Large responses | Arrival | No compression | tokers/zstd | ngx_mzstd, event loop | Pool, 1 task | Pool, 4 tasks | |---|---|--:|--:|--:|--:|--:| | Level 3, 178 per second | 8 at once | 1.9 | 9.0 | 6.9 | 1.7 | 1.8 | | Level 3, 178 per second | One at a time | 1.9 | 4.2 | 3.8 | 1.8 | 1.7 | | Level 19, 2 per second | 8 at once | 1.9 | 1,530 | 1,200 | 2.0 | 2.0 | | Level 19, 2 per second | One at a time | 1.9 | 198 | 155 | 1.8 | 1.9 | "8 at once" is the large stream's 8 connections, which wrk2 starts together and which stay in phase, as when several clients ask for large files at the same moment. "One at a time" is a single connection at a constant interval, the lower bound of the effect. On the pool, no small request took more than 3.6 ms one at a time. Without a thread pool, ngx_mzstd lowers p99 by 10–22% against tokers/zstd-nginx-module, only because mzstd compresses faster. It blocks the event loop all the same. The difference in tail latency comes from the thread pool. The large responses gained too. With 8 arriving together at level 19, each took a median 319 ms with 4 tasks, 465 ms with 1 task and 789 ms with tokers/zstd-nginx-module. ## How many requests does one worker serve? The same worker under closed-loop load, with wrk and 8 connections: Numbers as a table: | Response | Level | tokers/zstd | ngx_mzstd, event loop | Difference | Pool, 1 task | Difference | Pool, 4 tasks | Difference | |---|--:|--:|--:|--:|--:|--:|--:|--:| | jquery.min.js, 85 KB | 3 | 3,469 | 4,127 | +19% | 12,850 | 3.7× | 12,710 | 3.7× | | jquery.min.js, 85 KB | 9 | 841 | 814 | −3% | 3,162 | 3.8× | 3,077 | 3.7× | | jquery.min.js, 85 KB | 15 | 50.7 | 200 | 4.0× | 764 | 15.1× | 765 | 15.1× | | jquery.min.js, 85 KB | 19 | 31.3 | 83.9 | 2.7× | 323 | 10.3× | 324 | 10.3× | | react-dom.development.js, 1.03 MB | 3 | 357 | 414 | +16% | 1,283 | 3.6× | 1,278 | 3.6× | | react-dom.development.js, 1.03 MB | 9 | 91.1 | 90.6 | −1% | 251 | 2.8× | 244 | 2.7× | | react-dom.development.js, 1.03 MB | 15 | 12.0 | 15.8 | +32% | 47.7 | 4.0× | 45.9 | 3.8× | | react-dom.development.js, 1.03 MB | 19 | 4.99 | 6.42 | +29% | 16.5 | 3.3× | 16.4 | 3.3× | | libraries.json, 2.2 MB | 3 | 174 | 204 | +17% | 656 | 3.8× | 658 | 3.8× | | libraries.json, 2.2 MB | 9 | 48.0 | 48.6 | +1% | 112 | 2.3× | 111 | 2.3× | | libraries.json, 2.2 MB | 15 | 6.99 | 7.99 | +14% | 18.7 | 2.7× | 15.6 | 2.2× | | libraries.json, 2.2 MB | 19 | 2.45 | 3.05 | +24% | 6.54 | 2.7× | 6.54 | 2.7× | The pool's 4 threads use the 4 CPUs that one worker in the event loop cannot. On the 1–2.2 MB files at levels 9–19 the pool gains only 2.1–3.0× over the event loop, because of the extra CPU time explained below. **Small responses at high levels.** On `small.json` (1.9 KB) at levels 15 and 19, tokers/zstd-nginx-module served 46–58 requests per second, spending 18–22 ms of CPU time and up to 85 MiB on each response. ngx_mzstd served 5,100–9,200 in the event loop and 17,000–29,000 on the pool. tokers/zstd-nginx-module probably sizes its encoder for the level's full window because it does not pass the response length to zstd; that was not verified. ## How fast is one large response? Time to serve one `libraries.json` (2.2 MB, three 1 MiB segments) over one connection: Numbers as a table: | Level | tokers/zstd | ngx_mzstd, event loop | Pool, 1 task | Pool, 4 tasks | |--:|--:|--:|--:|--:| | 3 | 5.8 | 4.9 | 5.6 | 3.4 | | 9 | 20.7 | 21.0 | 31.1 | 17.4 | | 15 | 143 | 122 | 154 | 87 | | 19 | 417 | 334 | 557 | 251 | 4 tasks served it 1.6–2.2 times as fast as 1 task, and 1.2–1.7 times as fast as tokers/zstd-nginx-module. With 1 task, the pool was 26–67% slower than the event loop at levels 9–19: that is the segment overlap. Responses up to 1 MiB fit in one segment, so they do not depend on the number of tasks. ## Where the extra CPU time goes Numbers as a table: | Response | Level | tokers/zstd | ngx_mzstd, event loop | Pool, 1 task | Pool, 4 tasks | |---|--:|--:|--:|--:|--:| | react-dom.development.js, 1.03 MB | 3 | 2.8 | 2.4 | 3.0 | 3.1 | | react-dom.development.js, 1.03 MB | 9 | 11.0 | 11.1 | 15.9 | 16.4 | | react-dom.development.js, 1.03 MB | 19 | 201 | 157 | 243 | 247 | | libraries.json, 2.2 MB | 9 | 20.8 | 20.6 | 35.5 | 36.1 | | libraries.json, 2.2 MB | 15 | 145 | 126 | 216 | 267 | | libraries.json, 2.2 MB | 19 | 411 | 331 | 616 | 625 | At levels 9–19 the pool needed 1.4–2.1 times the CPU time of the event loop, and in most cases more than tokers/zstd-nginx-module. Two causes were measured. **Segment overlap.** Before compressing its own input, every segment but the first compresses and throws away the end of the previous segment, so that its matches can reach back. At levels that use the lazy match finders, that is 1/8 of the window: on `libraries.json`, 512 KiB for every 1 MiB segment. At levels 19 and above, a segment loads the whole window as a prefix. Measured in the library alone, on one thread, with 1 MiB segments, `libraries.json` took 1.21–1.69 times the CPU time of the event loop at levels 9–19, and 0.79–0.98 times without the overlap. The library measurements as a table: | Measurement | Level 9 | Level 15 | Level 19 | |---|--:|--:|--:| | ngx_mzstd, event loop, 8 connections | 20.6 | 126 | 331 | | mzstd, 1 MiB segments | 30.1 (×1.45) | 156 (×1.21) | 559 (×1.69) | | mzstd, 1 MiB segments without overlap | 20.3 (×0.98) | 101 (×0.79) | 315 (×0.95) | | ngx_mzstd, pool, 1 task, 1 connection | 31.4 | 156 | 558 | | ngx_mzstd, pool, 1 task, 8 connections | 35.5 | 216 | 616 | CPU time per `libraries.json` response, in ms. Without overlap, compressing in segments costs no more than compressing in one piece. At 1 connection, ngx_mzstd on the pool costs what the library costs, within 0.1×, so handing segments to the pool costs almost nothing. The overlap buys compression ratio: without it, the output would be 2.5–6.2% larger instead of 0.04–1.4%. **Encoders allocated anew.** A worker keeps encoders for `zstd_parallel_compressors` compressors, 4 by default. With 8 responses in flight, the others allocate their tables for every response. At level 15, that showed up as 14,000 page faults and 23 ms of system time per response with 1 task, and 35,000 and 64 ms with 4 tasks. `zstd_parallel_compressors 8` lowered CPU time per request from 211 to 185 ms with 1 task and from 272 to 185 ms with 4 tasks, and served 14% and 46% more requests. About 30 ms per response at level 15 remains unexplained (185 ms against 156 ms at 1 connection). It is not threads competing for cache: a pool of one thread needed the same 181 ms. ## What the thread pool costs **Memory.** On a pool, every response being sent holds encoders for its segments until it is sent, so peak memory grows with the number of responses in flight and the number of tasks per response. Numbers as a table: | Response | Level | tokers/zstd | ngx_mzstd, event loop | Pool, 1 task | Pool, 4 tasks | |---|--:|--:|--:|--:|--:| | react-dom.development.js, 1.03 MB | 3 | 6 | 7 | 52 | 66 | | react-dom.development.js, 1.03 MB | 9 | 16 | 16 | 153 | 222 | | react-dom.development.js, 1.03 MB | 15 | 70 | 38 | 419 | 573 | | react-dom.development.js, 1.03 MB | 19 | 86 | 39 | 485 | 562 | | libraries.json, 2.2 MB | 3 | 7 | 8 | 63 | 89 | | libraries.json, 2.2 MB | 9 | 17 | 17 | 182 | 304 | | libraries.json, 2.2 MB | 15 | 70 | 71 | 524 | 1,119 | | libraries.json, 2.2 MB | 19 | 87 | 56 | 513 | 961 | In the event loop, ngx_mzstd needs about as much as tokers/zstd-nginx-module, and less at levels 15 and 19. On the pool it needs 6–18 times as much, up to 1.1 GiB. **Compression ratio.** With 1m segments, responses longer than one segment came out 0–1.3% larger than in the event loop. With 4m, the files here were the same size. ## How to configure it - **Set `zstd_parallel_segment_size 4m`.** Both files here then fit in one segment, so a response is compressed by one task with no overlap. With 1 task at 8 connections, CPU time per request was 1.04–1.21 times the event loop's instead of 1.20–1.97, a worker served 15–69% more requests, peak memory was up to 31% lower and the output was as small as in the event loop. One response at a time was served within 6% of the event loop's time. The cost: only responses longer than 4 MiB are split across threads. Numbers as a table: | Response | Level | CPU, event loop | CPU, 1m | CPU, 4m | Peak memory, 1m | Peak memory, 4m | |---|--:|--:|--:|--:|--:|--:| | react-dom.development.js, 1.03 MB | 3 | 2.4 ms | 3.0 ms | 2.5 ms | 55 MiB | 43 MiB | | react-dom.development.js, 1.03 MB | 9 | 11.1 ms | 15.9 ms | 12.5 ms | 171 MiB | 139 MiB | | react-dom.development.js, 1.03 MB | 15 | 63.8 ms | 90.1 ms | 77.3 ms | 423 MiB | 312 MiB | | react-dom.development.js, 1.03 MB | 19 | 157 ms | 251 ms | 170 ms | 456 MiB | 314 MiB | | libraries.json, 2.2 MB | 3 | 4.9 ms | 5.9 ms | 5.1 ms | 59 MiB | 60 MiB | | libraries.json, 2.2 MB | 9 | 20.6 ms | 36.0 ms | 23.9 ms | 177 MiB | 161 MiB | | libraries.json, 2.2 MB | 15 | 126 ms | 222 ms | 196 ms | 527 MiB | 492 MiB | | libraries.json, 2.2 MB | 19 | 331 ms | 653 ms | 385 ms | 505 MiB | 414 MiB | Pool with 1 task, 8 connections. Level 15 on the 2.2 MB file stays at 1.56 times the event loop's CPU time, because of the encoders allocated anew. - **Raise `zstd_parallel_compressors`** to about the number of responses a worker compresses at once. With 8 connections, `8` instead of the default 4 cut CPU time at level 15 by 12–32%. What it does to peak memory was not measured. - **Declare a pool of your own** with `thread_pool zstd threads=N;`. The benchmark used 4 threads on 4 CPUs; pool sizes were not compared. - **Lower `zstd_parallel_tasks` to save memory.** On the 2.2 MB file, 4 tasks needed up to 2.1 times the memory of 1, and under load served no more requests. - **Bound encoder memory** between responses with `zstd_retention_policy bounded ` or `release_all`. The [ngx_mzstd README](https://github.com/M-Compressors/ngx_mzstd#configuration-directives) documents every directive. ## When you do not need it - **Static files** are better compressed once, at build time, and served with `zstd_static on;`. - **Low levels on small responses.** At level 3 a 1 MB response took 2.8 ms of CPU time with tokers/zstd-nginx-module, and other requests waited up to 4.2–9.0 ms at p99. If that is acceptable, the pool mostly adds memory. - **A CDN that compresses** for you takes the work off NGINX. ## How it was measured **Hardware.** Intel Core i7-13700KF, Windows 10.0.26300.9711, WSL 2.7.14.0 with kernel 6.18.33.2-microsoft-standard-WSL2, Ubuntu 22.04.5. WSL2 shows 12 identical cores with 2 threads each and does not tell which are performance cores, so some of the pinned CPUs may be efficiency cores. Traffic went over loopback without TLS, and files were served from tmpfs (`/dev/shm`). **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 `057a7d3` with libzstd 1.5.7. ngx_mzstd 0.2.2 (commit `ccd2bcf`) with mzstd 0.2.7, built with rustc 1.99.0. The current release is ngx_mzstd 0.2.3; the run has not been repeated on it. **Configuration.** `worker_processes 1`, with the worker and its pool pinned to 4 physical cores, and `thread_pool cp threads=4`. On the pool, `zstd_thread_pool cp` with `zstd_parallel_tasks 1` or the default 4. Everything else was left at its default, including 1m segments, `zstd_parallel_compressors 4` and encoder reuse, except where a section says otherwise. **Load.** Requests per second and CPU time: wrk 4.1.0 on 4 other physical cores, 2 threads and 8 connections, 4 seconds per run (8 seconds for files over 1 MB, 5 times as long at level 19) after a 1-second warm-up. NGINX was restarted for every run, and each run started once the worker had been idle for 300 ms. Three runs, in an order shuffled between runs; tables give the median. CPU time is the worker's user and system time from `/proc//stat`, including the pool's threads. Latency: two wrk2 streams (commit `44a94c1`) at once, open loop, with coordinated omission corrected; 8 at once is the median of three 30-second runs, one at a time is a single 30-second run. The library measurements ran on one core, the median of 5–7 iterations. **Files.** jQuery 3.7.1 (`jquery.min.js`, 85 KB) and React DOM 18.3.1 (`react-dom.development.js`, 1.03 MB) from cdnjs, the full cdnjs API listing (`libraries.json`, 2.2 MB) and its first 10 entries (`small.json`, 1.9 KB), all fetched on 20 October 2026. **Correctness.** Before the runs, every response in every combination of module, variant, level and file was decoded with curl's zstd decoder and compared with the original by SHA-256. All matched. **Not measured.** gzip, more than one worker process, pool sizes other than 4 threads, a response-size threshold for the pool, HTTP/2 and HTTP/3, slow clients, long runs, responses longer than 4 MiB and bare-metal Linux. Runs were 3 per cell and 4–40 seconds long, shorter than a full benchmark. ## Install it ngx_mzstd has packages for Debian, Ubuntu, RHEL and its derivatives, and Amazon Linux: [install it from packages](/2026/install-ngx-mzstd-packages/), or [build it from source](/2026/build-ngx-mzstd-from-source/). Existing tokers/zstd-nginx-module configuration keeps working; add `zstd_thread_pool` to move compression off the event loop. The same measurements for Brotli, with ngx_mbrotli against google/ngx_brotli, are in [Brotli on NGINX thread pools](/2026/ngx-mbrotli-thread-pool-benchmarks/). Questions: Q: Does zstd compression block the NGINX event loop? A: Yes, with tokers/zstd-nginx-module. The response is compressed by the worker that serves it, inside its event loop, so the worker’s other connections wait. With ngx_mzstd and zstd_thread_pool, compression runs on an NGINX thread pool and the event loop keeps serving other connections. Q: How do I compress zstd on an NGINX thread pool? A: Install ngx_mzstd, which accepts tokers/zstd-nginx-module’s configuration unchanged, and add zstd_thread_pool default; next to zstd on;. NGINX must be built with --with-threads. To size the pool on its own, declare thread_pool zstd threads=4; in the main context and use zstd_thread_pool zstd;. Q: How much faster is ngx_mzstd than tokers/zstd-nginx-module? A: With a 4-thread pool, one worker served 2.2–15 times as many requests on 85 KB–2.2 MB files. Without the pool, in the event loop like tokers/zstd-nginx-module, it served 0.97–4.0 times as many, the most at level 15 on an 85 KB file. Q: Which zstd_parallel_segment_size should I use? A: 4m rather than the default 1m. With 1m, every segment but the first re-reads part of the previous input, and that costs up to twice the CPU time per response. With 4m, responses of 1 and 2.2 MB needed 1.04–1.21 times the CPU time of the event loop in all but one case, a worker served 15–69% more requests, and the output was as small as without the pool. Only responses longer than 4 MiB are then split across threads. Q: How much memory does ngx_mzstd need on a thread pool? A: Much more than in the event loop. At 8 connections on 1–2.2 MB responses, one worker peaked at 52–1,119 MiB, 6–18 times as much as tokers/zstd-nginx-module. In the event loop its memory is close to tokers/zstd-nginx-module’s, and lower at levels 15 and 19. Q: Does ngx_mzstd need libzstd? A: No. It is built on mzstd, a Zstandard implementation in safe Rust whose single-threaded output is byte-identical to zstd 1.5.7. --- ## Brotli on NGINX thread pools: ngx_mbrotli vs ngx_brotli URL: https://m-compressors.github.io/2026/ngx-mbrotli-thread-pool-benchmarks/ Published: 2026-10-22 On-the-fly Brotli blocks the NGINX event loop. ngx_mbrotli on a thread pool against google/ngx_brotli: p99 latency, requests per second, CPU time and memory. Summary: - With google/ngx_brotli, each response is compressed inside the NGINX worker’s event loop, so every other request on that worker waits. While large responses were compressed at Brotli levels 6 and 11, the p99 latency of small uncompressed requests rose from 1.9 ms to 17–514 ms. - ngx_mbrotli with brotli_thread_pool compresses on NGINX thread pools. In the same test, the p99 of small requests stayed at 1.7–3.3 ms, the same as with no compression. - One NGINX worker with a 4-thread pool served 3.8–15 times as many Brotli responses per second as google/ngx_brotli on 85 KB–2.2 MB files. The gain was largest at levels 9 and 11. - The cost is memory. At 8 connections on 1–2.2 MB responses, a worker peaked at 138–995 MiB, 11–26 times as much as google/ngx_brotli. Responses longer than one 1 MiB segment came out 0.9–5.2% larger. - Measured on 20 October 2026 with NGINX 1.30.6, one worker, an Intel Core i7-13700KF under WSL2. Every response was decoded and checked against the original by SHA-256. **ngx_mbrotli** is an NGINX module for on-the-fly Brotli compression, written in Rust on top of the [mbrotli](https://github.com/M-Compressors/mbrotli) encoder. It is a drop-in replacement for [google/ngx_brotli](https://github.com/google/ngx_brotli), with the same directives, defaults and `$brotli_ratio` variable. Unlike google/ngx_brotli, it can compress on NGINX thread pools, so that compressing a large response does not stop the worker from serving anything else. This post measures what that changes. One NGINX worker served large files compressed on the fly while a second stream of small uncompressed requests went to the same worker. With google/ngx_brotli, the 99th percentile latency of the small requests rose from 1.9 ms to as much as 514 ms. With ngx_mbrotli on a 4-thread pool it stayed at 1.7–3.3 ms. The numbers come from a reduced run under WSL2 on 20 October 2026. They are good for comparing the modules with each other, less so as absolute figures for your hardware. [How it was measured](#how-it-was-measured) has the details and what was not measured. ## Why compression delays other requests An NGINX worker process serves thousands of connections from one thread. Its event loop waits for sockets to become ready, runs a short piece of work for each one, and goes back to waiting. That works because each piece of work is short. A filter that runs for 100 ms holds every other connection of that worker for 100 ms. Compression is such a filter. google/ngx_brotli and the stock gzip module compress in the event loop. When a 1 MB JavaScript bundle is compressed at Brotli level 6, it takes about 16 ms of CPU time, and at level 11 about 1.2 s. A small request that arrives on the same worker meanwhile waits for compression to end. When several large responses arrive at once, the worker compresses them back to back, and the small request waits for all of them. NGINX has had a way out since version 1.7.11, released on 24 March 2015 with "experimental thread pools support" ([CHANGES](https://nginx.org/en/CHANGES)). NGINX itself uses thread pools for blocking file I/O (`aio threads`). ngx_mbrotli uses them for compression. ## How ngx_mbrotli compresses on a thread pool With `brotli_thread_pool`, the module cuts a response into segments of `brotli_parallel_segment_size`, 1 MiB by default. Each segment becomes an NGINX thread task (`ngx_thread_task_t`) and is compressed on one of the pool's threads. Up to `brotli_parallel_tasks` segments of one response, 4 by default, are compressed at once. A response that fits in one segment is compressed by one thread, in one piece. When a task is done, its completion handler runs in the event loop. It passes the compressed bytes down the filter chain in order and wakes the connection, as a write event would. While tasks are in flight, the request stays marked as blocked and buffered, so NGINX neither finalizes it early nor frees memory a thread is still using. Meanwhile the event loop serves other connections. Segments are compressed independently: Brotli back-references do not cross segment boundaries. That makes segments safe to compress in parallel. It also makes a response longer than one segment compress slightly worse, as the numbers below show. ```nginx load_module modules/ngx_mbrotli.so; thread_pool brotli threads=4; http { brotli on; brotli_comp_level 6; brotli_types text/plain text/css application/javascript application/json image/svg+xml; brotli_thread_pool brotli; } ``` NGINX must be built with `--with-threads`; `nginx -V` shows it. nginx.org's packages are. The pool named `default` exists without being declared, with 32 threads per worker, and is shared with `aio threads`. ## How long do other requests wait? Small uncompressed requests (`small.json`, 1.9 KB) were sent at 1,000 per second over 32 connections. At the same time, a second stream requested large compressed files at a constant rate of about half of what google/ngx_brotli can sustain. All variants kept up with both streams. Numbers as a table: | Large responses | Arrival | No compression | google/ngx_brotli | ngx_mbrotli, event loop | Pool, 1 task | Pool, 4 tasks | |---|---|--:|--:|--:|--:|--:| | react-dom.development.js, level 6, 31 per second | 8 at once | 1.9 | 115 | 94 | 3.3 | 2.8 | | react-dom.development.js, level 6, 31 per second | One at a time | 1.9 | 17.0 | 14.7 | 1.7 | 1.8 | | jquery.min.js, level 11, 7 per second | 8 at once | 1.9 | 514 | 306 | 2.0 | 1.9 | | jquery.min.js, level 11, 7 per second | One at a time | 1.9 | 71.2 | 45.7 | 1.8 | 1.9 | "8 at once" is the large stream's 8 connections, which wrk2 starts together and which stay in phase. It is what happens when several clients ask for large files at the same moment. In the event loop, a small request then waits for up to 8 compressions. "One at a time" is a single connection at a constant interval, where a small request waits for at most one. It is the lower bound of the effect: the pool cut p99 by 9–40 times there. On the pool, the maximum latency of a small request one at a time was 3.6 ms. Without a thread pool, ngx_mbrotli lowers p99 by 14–41% against google/ngx_brotli, only because mbrotli compresses faster. It blocks the event loop all the same. The difference in tail latency comes from moving compression off the event loop, not from a faster encoder. ## How many requests does one worker serve? The same worker under closed-loop load, with wrk and 8 connections: Numbers as a table: | Response | Level | google/ngx_brotli | ngx_mbrotli, event loop | Difference | Pool, 1 task | Difference | Pool, 4 tasks | Difference | |---|--:|--:|--:|--:|--:|--:|--:|--:| | jquery.min.js, 85 KB | 4 | 985 | 1,135 | +15% | 4,180 | 4.2× | 4,154 | 4.2× | | jquery.min.js, 85 KB | 6 | 674 | 706 | +5% | 2,528 | 3.8× | 2,545 | 3.8× | | jquery.min.js, 85 KB | 9 | 108 | 472 | 4.4× | 1,655 | 15.3× | 1,669 | 15.4× | | jquery.min.js, 85 KB | 11 | 14.9 | 24.4 | +64% | 94.5 | 6.4× | 95.0 | 6.4× | | react-dom.development.js, 1.03 MB | 4 | 134 | 154 | +15% | 512 | 3.8× | 512 | 3.8× | | react-dom.development.js, 1.03 MB | 6 | 63.3 | 75.3 | +19% | 282 | 4.5× | 276 | 4.4× | | react-dom.development.js, 1.03 MB | 9 | 24.5 | 40.1 | +64% | 110 | 4.5× | 128 | 5.2× | | react-dom.development.js, 1.03 MB | 11 | 1.02 | 1.60 | +57% | 9.76 | 9.6× | 9.78 | 9.6× | | libraries.json, 2.2 MB | 4 | 68.4 | 78.1 | +14% | 263 | 3.8× | 268 | 3.9× | | libraries.json, 2.2 MB | 6 | 32.7 | 37.8 | +16% | 146 | 4.5× | 142 | 4.3× | | libraries.json, 2.2 MB | 9 | 14.8 | 20.1 | +35% | 63.3 | 4.3× | 63.3 | 4.3× | | libraries.json, 2.2 MB | 11 | 0.55 | 0.87 | +58% | 5.11 | 9.3× | 5.26 | 9.6× | The pool's 4 threads use the 4 CPUs that google/ngx_brotli, with one worker, cannot. That explains about 4× at levels 4–6. The rest comes from the encoder. At level 9 the C encoder allocates a 32 MiB hash table for every response over 64 KB and frees it afterwards. That cost does not depend on the response size, so mbrotli's lead is largest on the 85 KB file. At level 11 a 1 MiB segment compresses faster than a whole file, so the pool gains more than its 4 threads. With 8 connections, 1 and 4 tasks per response served within 3% of each other, with one exception (+17%). The pool's threads were already busy with different responses. ## How fast is one large response? More tasks per response help when a response is longer than one segment and nothing else competes for the threads: Numbers as a table: | Level | google/ngx_brotli | ngx_mbrotli, event loop | Pool, 1 task | Pool, 4 tasks | |--:|--:|--:|--:|--:| | 4 | 14.6 | 12.9 | 13.1 | 6.6 | | 6 | 30.6 | 26.6 | 25.2 | 12.5 | | 9 | 69.1 | 50.1 | 54.9 | 24.1 | | 11 | 2,004 | 1,253 | 771 | 371 | 4 tasks served it 2.0–2.3 times as fast as 1 task, and 2.2–5.4 times as fast as google/ngx_brotli. Responses up to 1 MiB fit in one segment, so the number of tasks does not change how fast they are served. `react-dom.development.js` is a 1 MiB segment and a 31 KB one, so 4 tasks served it only 4–34% faster than 1. ## Brotli level 11 on the fly Level 11 is usually kept for pre-compressed static files, because one response can hold a worker for a second or more. On a thread pool, that time no longer holds up other requests: next to a stream of level-11 responses, small requests kept a p99 of 1.8–2.0 ms. A 2.2 MB response took 371 ms with 4 tasks, against 2,004 ms with google/ngx_brotli. Level 11 still costs CPU. `libraries.json` needed 785 ms of CPU time per response on the pool, and the worker served 5.3 such responses per second on 4 CPUs. It fits responses that are large, compressible and requested rarely enough, not a busy API. ## What the thread pool costs **Memory.** In the event loop, a worker compresses one response at a time and needs one encoder. On a pool, every response being sent holds encoders for its segments until it is sent. Peak memory then grows with the number of responses in flight and with the number of tasks per response. Numbers as a table: | Response | Level | google/ngx_brotli | ngx_mbrotli, event loop | Pool, 1 task | Pool, 4 tasks | |---|--:|--:|--:|--:|--:| | react-dom.development.js, 1.03 MB | 4 | 9 | 12 | 117 | 138 | | react-dom.development.js, 1.03 MB | 6 | 11 | 14 | 144 | 143 | | react-dom.development.js, 1.03 MB | 9 | 39 | 41 | 482 | 483 | | react-dom.development.js, 1.03 MB | 11 | 25 | 28 | 296 | 269 | | libraries.json, 2.2 MB | 4 | 9 | 14 | 143 | 215 | | libraries.json, 2.2 MB | 6 | 11 | 14 | 210 | 276 | | libraries.json, 2.2 MB | 9 | 39 | 43 | 519 | 995 | | libraries.json, 2.2 MB | 11 | 28 | 30 | 390 | 505 | In the event loop, ngx_mbrotli needs 2–5 MiB more than google/ngx_brotli. On the pool, it needs 11–26 times as much. **CPU time.** CPU time per request, including the pool's threads, was within 0.6–1.4 times that of the event loop. At level 11 the pool needed 37–40% less, because 1 MiB segments compress faster than a whole file. At level 9 it needed 22–40% more. Numbers as a table: | Response | Level | google/ngx_brotli | ngx_mbrotli, event loop | Pool, 1 task | Pool, 4 tasks | |---|--:|--:|--:|--:|--:| | react-dom.development.js, 1.03 MB | 6 | 16.0 | 13.4 | 14.2 | 14.5 | | react-dom.development.js, 1.03 MB | 9 | 42.3 | 25.6 | 35.8 | 31.2 | | react-dom.development.js, 1.03 MB | 11 | 1,153 | 695 | 414 | 415 | | libraries.json, 2.2 MB | 9 | 71.4 | 51.1 | 63.0 | 62.9 | | libraries.json, 2.2 MB | 11 | 2,432 | 1,256 | 789 | 785 | **Compression ratio.** Responses longer than one segment came out 0.9–5.2% larger, because segments are compressed independently. Responses up to one segment are byte-for-byte what the event loop produces. ## How to configure it The defaults suit most sites: a 1 MiB segment and 4 tasks per response. What to change and why: - **Declare a pool of your own** with `thread_pool brotli threads=N;`. The benchmark used 4 threads on 4 CPUs. Pool sizes were not compared, so start near the number of CPUs a worker can use. - **Keep `brotli_parallel_segment_size 1m`.** With `4m`, both files here fit in one segment, so the output is as small as in the event loop and peak memory is 19–42% lower. At levels 4–9, it served between 9% fewer and 14% more requests per second than 1m. At level 11 it served 35–37% fewer, because the whole file compresses slower than 1 MiB segments. With 4m, only responses longer than 4 MiB are split across threads. - **Lower `brotli_parallel_tasks` to save memory** when single responses are rarely longer than a segment. On the 2.2 MB file, 4 tasks needed up to 1.9 times the memory of 1. Under load, 1 and 4 tasks served about the same number of requests. - **Bound encoder memory** with `brotli_parallel_compressors` (4 by default), which limits how many compressors a worker keeps encoders for, and with `brotli_retention_policy bounded ` or `release_all`, which keep less memory between responses. - **Turn the pool off per location** with `brotli_thread_pool off;`. Compressing a small response in the event loop holds the loop only briefly. In this run the pool still served 1.5–5.4 times as many 1.9 KB responses per second, so there is no measured reason to turn it off for small responses. A size threshold was not measured. The [ngx_mbrotli README](https://github.com/M-Compressors/ngx_mbrotli#configuration-directives) documents every directive. ## When you do not need it - **Static files** are better compressed once, at build time, at level 11, and served with `brotli_static on;`. Nothing is compressed per request then. - **A CDN that compresses** for you takes the work off NGINX. Traffic between the CDN and NGINX may still be worth compressing. - **Small responses only.** If no response takes long to compress, the event loop is never held long, and the pool mostly adds memory. ## How it was measured **Hardware.** Intel Core i7-13700KF, Windows 10.0.26300.9711, WSL 2.7.14.0 with kernel 6.18.33.2-microsoft-standard-WSL2, Ubuntu 22.04.5. WSL2 shows 12 identical cores with 2 threads each and does not tell which are performance cores, so some of the pinned CPUs may be efficiency cores. Traffic went over loopback without TLS, and files were served from tmpfs (`/dev/shm`). **Build.** NGINX 1.30.6, `./configure --with-compat --with-threads --without-http_rewrite_module`, gcc 11.4.0, both modules dynamic. google/ngx_brotli at commit `a71f9312` with the Brotli 1.1.0 it pins as a submodule. ngx_mbrotli at commit `8f7508a` with mbrotli 0.5.10, built with rustc 1.99.0. This commit predates the 0.4.0 release; the current release is 0.4.5, and the run has not been repeated on it. **Configuration.** `worker_processes 1`, with the worker and its pool pinned to 4 physical cores, and `thread_pool cp threads=4`. On the pool, `brotli_thread_pool cp` with `brotli_parallel_tasks 1` or the default 4. Everything else was left at its default, including the 1m segment and encoder reuse. **Load.** Requests per second and CPU time: wrk 4.1.0 on 4 other physical cores, 2 threads and 8 connections, 4 seconds per run (8 seconds for files over 1 MB, 5 times as long at level 11) after a 1-second warm-up. NGINX was restarted for every run, and each run started once the worker had been idle for 300 ms. Three runs, in an order shuffled between runs; tables give the median. CPU time is the worker's user and system time from `/proc//stat`, including the pool's threads. Latency: two wrk2 streams (commit `44a94c1`) at once, open loop, with coordinated omission corrected; 8 at once is the median of three 30-second runs, one at a time is a single 30-second run. **Files.** jQuery 3.7.1 (`jquery.min.js`, 85 KB) and React DOM 18.3.1 (`react-dom.development.js`, 1.03 MB) from cdnjs, the full cdnjs API listing (`libraries.json`, 2.2 MB) and its first 10 entries (`small.json`, 1.9 KB), all fetched on 20 October 2026. **Correctness.** Before the runs, every response in every combination of module, variant, level and file was decoded with curl's Brotli decoder and compared with the original by SHA-256. All matched. **Not measured.** gzip, more than one worker process, pool sizes other than 4 threads, a response-size threshold for the pool, HTTP/2 and HTTP/3, slow clients, long runs and bare-metal Linux. Runs were 3 per cell and 4–40 seconds long, shorter than a full benchmark. ## Install it ngx_mbrotli has packages for Debian, Ubuntu, RHEL and its derivatives, and Amazon Linux: [install it from packages](/2026/install-ngx-mbrotli-packages/), or [build it from source](/2026/build-ngx-mbrotli-from-source/). Existing google/ngx_brotli configuration keeps working; add `brotli_thread_pool` to move compression off the event loop. The same measurements for Zstandard, with ngx_mzstd against tokers/zstd-nginx-module, are in [zstd on NGINX thread pools](/2026/ngx-mzstd-thread-pool-benchmarks/). Questions: Q: Does Brotli compression block the NGINX event loop? A: Yes, with google/ngx_brotli and the stock gzip module. The response is compressed by the worker process that serves it, inside its event loop, so the worker’s other connections wait until compression of that buffer ends. With ngx_mbrotli and brotli_thread_pool, compression runs on an NGINX thread pool and the event loop keeps serving other connections. Q: How do I compress Brotli on an NGINX thread pool? A: Install ngx_mbrotli, which accepts google/ngx_brotli’s configuration unchanged, and add brotli_thread_pool default; next to brotli on;. NGINX must be built with --with-threads. To size the pool on its own, declare thread_pool brotli threads=4; in the main context and use brotli_thread_pool brotli;. Q: How much faster is ngx_mbrotli than google/ngx_brotli? A: With a 4-thread pool, one worker served 3.8–4.5 times as many requests at levels 4–6 and 4.3–15 times as many at levels 9–11, on 85 KB–2.2 MB files. Without the pool, in the event loop like google/ngx_brotli, it served 5–64% more requests, and 4.4 times as many at level 9 on an 85 KB file. Q: How much memory does ngx_mbrotli need on a thread pool? A: Much more than in the event loop. Every response being sent holds encoders for its segments. At 8 connections on 1–2.2 MB responses, one worker peaked at 138–995 MiB against 9–39 MiB for google/ngx_brotli. brotli_parallel_tasks 1, brotli_parallel_compressors and brotli_retention_policy bounded <size> keep it lower. Q: Is Brotli level 11 practical for on-the-fly compression? A: It is still expensive: a 2.2 MB JSON file took 785 ms of CPU time at level 11. On a thread pool that time no longer delays other requests. With 4 tasks, one response is served in 371 ms instead of 2,004 ms with google/ngx_brotli, and one worker served 6.4–9.6 times as many level-11 responses per second. Q: Why not just add more NGINX worker processes? A: It was not measured here. A connection stays on the worker that accepted it, so a request that lands on a worker busy compressing still waits for it. More workers make that less likely; they do not shorten the wait. --- ## Building the ngx_mzstd Zstandard module for NGINX from source URL: https://m-compressors.github.io/2026/build-ngx-mzstd-from-source/ Published: 2026-10-22 How to build ngx_mzstd from source against nginx from nginx.org's official repository, on Debian, Ubuntu, RHEL-compatible and other Linux systems, or against an nginx you compile yourself. Summary: - Build ngx_mzstd against nginx from nginx.org’s official repository, or against an nginx you compile yourself with --with-compat. - A dynamic NGINX module loads only into the nginx version it was built for, configured the same way. - Building takes Rust 1.89 or newer, libclang, the PCRE2, OpenSSL and zlib headers, and your exact nginx version’s source run through ./configure with the arguments nginx -V prints. make is not needed. - NGINX_BUILD_DIR=<nginx source>/objs cargo build --release writes target/release/libngx_mzstd.so. The zstd C library is not needed. The [previous post](/2026/install-ngx-mzstd-packages/) installed **ngx_mzstd** from packages. Build it yourself when there is no package for your system, when you compile nginx yourself, or when you want a module version that has not been packaged yet. Every command here was run on fresh containers on 22 October 2026, against nginx.org's nginx on Debian 13 and AlmaLinux 9, and against nginx built from source on Ubuntu 24.04. Each one ended with nginx answering `Content-Encoding: zstd`. ## Install nginx from nginx.org nginx loads a dynamic module only if it was built for the same nginx version and the same configuration. nginx.org's packages are built with `--with-compat`, which makes a separately built module match them reliably, so on Alpine, Arch Linux, openSUSE and every other system, install nginx from [nginx.org's official repository](https://nginx.org/en/linux_packages.html) first. Check that your nginx has the flag: ```sh nginx -V 2>&1 | grep -o -- --with-compat ``` The module also needs nginx 1.25.4 or newer. To build nginx yourself instead, see [the end of this post](#building-nginx-too). ## Install the build tools You need a C toolchain, git, libclang (the Rust bindings to nginx are generated from its headers), and the headers nginx is compiled against. The zstd library is not among them. Debian and Ubuntu: ```sh sudo apt-get install build-essential curl git libclang-dev libpcre2-dev libssl-dev zlib1g-dev ``` RHEL, AlmaLinux, Rocky Linux and Oracle Linux: ```sh sudo dnf install redhat-rpm-config gcc make git tar clang-devel pcre2-devel openssl-devel zlib-devel ``` `redhat-rpm-config` provides the compiler specs that nginx.org's RPM builds pass to the compiler. Without it, `./configure` stops with `the invalid value in --with-ld-opt="-Wl,-z,relro -Wl,-z,now -pie"`. On other systems, install the same things with the system's package manager: a C compiler and make, git, curl, libclang, and the PCRE2, OpenSSL and zlib development headers. ## Install Rust ngx_mzstd needs Rust 1.89 or newer. Distribution packages are often older: Debian 13 ships 1.85. Install Rust with [rustup](https://rustup.rs) as the user who will build: ```sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal . "$HOME/.cargo/env" ``` ## Configure nginx's source like your nginx Download the source of exactly the version you run and configure it with the arguments your binary was built with. Only `./configure` is needed: the module reads its output in `objs/`, and nothing is compiled or installed. ```sh ver=$(nginx -v 2>&1 | sed 's|.*nginx/||') cd ~ curl -O "https://nginx.org/download/nginx-$ver.tar.gz" tar xzf "nginx-$ver.tar.gz" cd "nginx-$ver" eval "./configure $(nginx -V 2>&1 | sed -n 's/^configure arguments: //p')" ``` `eval` keeps quoted arguments such as `--with-cc-opt='-O2 -g ...'` in one piece. If `./configure` reports a missing library, install its development package and run it again. If you already configured this tree for [ngx_mbrotli](/2026/build-ngx-mbrotli-from-source/), reuse it. ## Build the module ```sh cd ~ git clone --depth 1 --branch v0.2.3 https://github.com/M-Compressors/ngx_mzstd cd ngx_mzstd NGINX_BUILD_DIR="$HOME/nginx-$ver/objs" cargo build --release ``` `v0.2.3` is the latest release at the time of writing; the [release page](https://github.com/M-Compressors/ngx_mzstd/releases) lists newer ones. The module is written to `target/release/libngx_mzstd.so`. ## Install and load it Copy the module into nginx's modules directory, which `nginx -V` shows as `--modules-path`: ```sh moddir=$(nginx -V 2>&1 | grep -o -- '--modules-path=[^ ]*' | cut -d= -f2) sudo install -m 644 target/release/libngx_mzstd.so "$moddir/" ``` Load it at the very top of `/etc/nginx/nginx.conf`: ```nginx load_module modules/libngx_mzstd.so; ``` Turn compression on as in the [previous post](/2026/install-ngx-mzstd-packages/#turn-compression-on), then check and restart: ```sh sudo nginx -t sudo systemctl restart nginx curl -sS -o /dev/null -D - -H 'Accept-Encoding: zstd' http://localhost/ | grep -i content-encoding ``` ## Building nginx too To build nginx yourself instead of installing it from nginx.org, configure it with `--with-compat`. This builds nginx 1.30.6 into `/usr/local/nginx` on Ubuntu 24.04, with the build tools from above installed: ```sh cd ~ curl -O https://nginx.org/download/nginx-1.30.6.tar.gz tar xzf nginx-1.30.6.tar.gz cd nginx-1.30.6 ./configure --prefix=/usr/local/nginx --with-compat --with-threads \ --with-http_ssl_module --with-http_v2_module make -j"$(nproc)" sudo make install ``` `--with-threads` lets the module compress on thread pools. Build ngx_mzstd against this tree with `NGINX_BUILD_DIR="$HOME/nginx-1.30.6/objs"` as above, copy `libngx_mzstd.so` to `/usr/local/nginx/modules/`, and load it with `load_module modules/libngx_mzstd.so;`. ## When nginx is upgraded A module built for one nginx version does not load into another, so after every nginx upgrade, download the new version's source, configure it, build the module again and restart nginx. A reload is not enough: the master process keeps the old module loaded. ## Errors you may see | Error | Cause | Fix | |---|---|---| | `module "…" is not binary compatible` | The source was configured differently from the running nginx, or that nginx lacks `--with-compat`. | Configure with exactly the arguments from `nginx -V`, with nginx from nginx.org or built with `--with-compat`. | | `module "…" version 1030005 instead of 1030006` | The module was built against another nginx version. | Build against the source of the version you run. | | `module "ngx_http_zstd_filter_module" is already loaded` | tokers/zstd-nginx-module is still loaded; the modules have the same names. | Remove its `load_module` line or package. | | `the invalid value in --with-ld-opt` on RHEL | The compiler specs used by nginx.org's RPM builds are missing. | Install `redhat-rpm-config`. | | `Unable to find libclang` during `cargo build` | libclang is not installed. | Install `libclang-dev` or `clang-devel`. | Questions: Q: Which nginx can I build ngx_mzstd for? A: nginx 1.25.4 or newer, installed from nginx.org’s official repository or built yourself with --with-compat. nginx.org’s packages are built with --with-compat, which lets a separately built module match them. Q: Does ngx_mzstd need libzstd? A: No. It contains mzstd, a Zstandard implementation in Rust, so neither the zstd library nor its headers are needed to build or run it. Q: What does "module is not binary compatible" mean? A: The module was built against nginx source configured differently from the running binary. Configure the source with exactly the arguments nginx -V prints, and use nginx from nginx.org or an nginx built with --with-compat. Q: Do I have to rebuild ngx_mzstd when I upgrade nginx? A: Yes. nginx refuses to load a module built for another nginx version. Build again against the new version’s source and restart nginx. --- ## Building the ngx_mbrotli Brotli module for NGINX from source URL: https://m-compressors.github.io/2026/build-ngx-mbrotli-from-source/ Published: 2026-10-22 How to build ngx_mbrotli from source against nginx from nginx.org's official repository, on Debian, Ubuntu, RHEL-compatible and other Linux systems, or against an nginx you compile yourself. Summary: - Build ngx_mbrotli against nginx from nginx.org’s official repository, or against an nginx you compile yourself with --with-compat. - A dynamic NGINX module loads only into the nginx version it was built for, configured the same way. - Building takes Rust 1.89 or newer, libclang, the PCRE2, OpenSSL and zlib headers, and your exact nginx version’s source run through ./configure with the arguments nginx -V prints. make is not needed. - NGINX_BUILD_DIR=<nginx source>/objs cargo build --release writes target/release/libngx_mbrotli.so. No brotli C library is needed. The [previous post](/2026/install-ngx-mbrotli-packages/) installed **ngx_mbrotli** from packages. Build it yourself when there is no package for your system, when you compile nginx yourself, or when you want a module version that has not been packaged yet. Every command here was run on fresh containers on 22 October 2026, against nginx.org's nginx on Debian 13 and AlmaLinux 9, and against nginx built from source on Ubuntu 24.04. Each one ended with nginx answering `Content-Encoding: br`. ## Install nginx from nginx.org nginx loads a dynamic module only if it was built for the same nginx version and the same configuration. nginx.org's packages are built with `--with-compat`, which makes a separately built module match them reliably, so on Alpine, Arch Linux, openSUSE and every other system, install nginx from [nginx.org's official repository](https://nginx.org/en/linux_packages.html) first. Check that your nginx has the flag: ```sh nginx -V 2>&1 | grep -o -- --with-compat ``` The module also needs nginx 1.25.4 or newer. To build nginx yourself instead, see [the end of this post](#building-nginx-too). ## Install the build tools You need a C toolchain, git, libclang (the Rust bindings to nginx are generated from its headers), and the headers nginx is compiled against. Debian and Ubuntu: ```sh sudo apt-get install build-essential curl git libclang-dev libpcre2-dev libssl-dev zlib1g-dev ``` RHEL, AlmaLinux, Rocky Linux and Oracle Linux: ```sh sudo dnf install redhat-rpm-config gcc make git tar clang-devel pcre2-devel openssl-devel zlib-devel ``` `redhat-rpm-config` provides the compiler specs that nginx.org's RPM builds pass to the compiler. Without it, `./configure` stops with `the invalid value in --with-ld-opt="-Wl,-z,relro -Wl,-z,now -pie"`. On other systems, install the same things with the system's package manager: a C compiler and make, git, curl, libclang, and the PCRE2, OpenSSL and zlib development headers. ## Install Rust ngx_mbrotli needs Rust 1.89 or newer. Distribution packages are often older: Debian 13 ships 1.85. Install Rust with [rustup](https://rustup.rs) as the user who will build: ```sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal . "$HOME/.cargo/env" ``` ## Configure nginx's source like your nginx Download the source of exactly the version you run and configure it with the arguments your binary was built with. Only `./configure` is needed: the module reads its output in `objs/`, and nothing is compiled or installed. ```sh ver=$(nginx -v 2>&1 | sed 's|.*nginx/||') cd ~ curl -O "https://nginx.org/download/nginx-$ver.tar.gz" tar xzf "nginx-$ver.tar.gz" cd "nginx-$ver" eval "./configure $(nginx -V 2>&1 | sed -n 's/^configure arguments: //p')" ``` `eval` keeps quoted arguments such as `--with-cc-opt='-O2 -g ...'` in one piece. If `./configure` reports a missing library, install its development package and run it again. ## Build the module ```sh cd ~ git clone --depth 1 --branch v0.4.5 https://github.com/M-Compressors/ngx_mbrotli cd ngx_mbrotli NGINX_BUILD_DIR="$HOME/nginx-$ver/objs" cargo build --release ``` `v0.4.5` is the latest release at the time of writing; the [release page](https://github.com/M-Compressors/ngx_mbrotli/releases) lists newer ones. The module is written to `target/release/libngx_mbrotli.so`. It contains the mbrotli encoder, so no brotli C library is needed. ## Install and load it Copy the module into nginx's modules directory, which `nginx -V` shows as `--modules-path`: ```sh moddir=$(nginx -V 2>&1 | grep -o -- '--modules-path=[^ ]*' | cut -d= -f2) sudo install -m 644 target/release/libngx_mbrotli.so "$moddir/" ``` Load it at the very top of `/etc/nginx/nginx.conf`: ```nginx load_module modules/libngx_mbrotli.so; ``` Turn compression on as in the [previous post](/2026/install-ngx-mbrotli-packages/#turn-compression-on), then check and restart: ```sh sudo nginx -t sudo systemctl restart nginx curl -sS -o /dev/null -D - -H 'Accept-Encoding: br' http://localhost/ | grep -i content-encoding ``` ## Building nginx too To build nginx yourself instead of installing it from nginx.org, configure it with `--with-compat`. This builds nginx 1.30.6 into `/usr/local/nginx` on Ubuntu 24.04, with the build tools from above installed: ```sh cd ~ curl -O https://nginx.org/download/nginx-1.30.6.tar.gz tar xzf nginx-1.30.6.tar.gz cd nginx-1.30.6 ./configure --prefix=/usr/local/nginx --with-compat --with-threads \ --with-http_ssl_module --with-http_v2_module make -j"$(nproc)" sudo make install ``` `--with-threads` lets the module compress on thread pools. Build ngx_mbrotli against this tree with `NGINX_BUILD_DIR="$HOME/nginx-1.30.6/objs"` as above, copy `libngx_mbrotli.so` to `/usr/local/nginx/modules/`, and load it with `load_module modules/libngx_mbrotli.so;`. ## When nginx is upgraded A module built for one nginx version does not load into another, so after every nginx upgrade, download the new version's source, configure it, build the module again and restart nginx. A reload is not enough: the master process keeps the old module loaded. ## Errors you may see | Error | Cause | Fix | |---|---|---| | `module "…" is not binary compatible` | The source was configured differently from the running nginx, or that nginx lacks `--with-compat`. | Configure with exactly the arguments from `nginx -V`, with nginx from nginx.org or built with `--with-compat`. | | `module "…" version 1030005 instead of 1030006` | The module was built against another nginx version. | Build against the source of the version you run. | | `module "ngx_http_brotli_filter_module" is already loaded` | google/ngx_brotli is still loaded; the modules have the same names. | Remove its `load_module` line or package. | | `the invalid value in --with-ld-opt` on RHEL | The compiler specs used by nginx.org's RPM builds are missing. | Install `redhat-rpm-config`. | | `Unable to find libclang` during `cargo build` | libclang is not installed. | Install `libclang-dev` or `clang-devel`. | To add Zstandard as well, [build ngx_mzstd](/2026/build-ngx-mzstd-from-source/) the same way against the same nginx source. Questions: Q: Which nginx can I build ngx_mbrotli for? A: nginx 1.25.4 or newer, installed from nginx.org’s official repository or built yourself with --with-compat. nginx.org’s packages are built with --with-compat, which lets a separately built module match them. Q: What does "module is not binary compatible" mean? A: The module was built against nginx source configured differently from the running binary. Configure the source with exactly the arguments nginx -V prints, and use nginx from nginx.org or an nginx built with --with-compat. Q: Do I have to rebuild ngx_mbrotli when I upgrade nginx? A: Yes. nginx refuses to load a module built for another nginx version. Build again against the new version’s source and restart nginx. Q: Which Rust version does ngx_mbrotli need? A: Rust 1.89 or newer. Distribution packages of Rust are often older, so install it with rustup. --- ## Installing Zstandard for NGINX from packages: ngx_mzstd URL: https://m-compressors.github.io/2026/install-ngx-mzstd-packages/ Published: 2026-10-22 How to install the ngx_mzstd Zstandard module with apt or dnf on Debian, Ubuntu, RHEL, AlmaLinux, Rocky Linux and Amazon Linux, for nginx.org's nginx and for the distribution's own. Summary: - Prebuilt ngx_mzstd packages exist for amd64 and arm64, for nginx.org stable and mainline and for the nginx shipped by Debian 13, Ubuntu 26.04 and RHEL 10. - With nginx.org’s nginx the package is nginx-module-mzstd and needs a load_module line; the distribution packages enable themselves. - ngx_mzstd is a drop-in replacement for tokers/zstd-nginx-module: the same directives, defaults and variable. It does not need the zstd C library. - With ngx_mbrotli loaded too, nginx answers Content-Encoding: zstd to clients that accept both, in either load_module order. - Each package is built against one exact nginx package, so the module and nginx are upgraded together. Restart nginx after an upgrade, not just reload. **ngx_mzstd** adds Zstandard compression to NGINX. It is a drop-in replacement for tokers/zstd-nginx-module, written in Rust on top of the [mzstd](https://github.com/M-Compressors/mzstd) encoder, so it needs no zstd C library, and it can compress on NGINX thread pools. This post installs it from packages. If there is no package for your system, [build it from source](/2026/build-ngx-mzstd-from-source/). Every command below was run on 22 October 2026 on fresh containers of Debian 13 and AlmaLinux 10 with their own nginx, and of Ubuntu 24.04, Rocky Linux 9 and Amazon Linux 2023 with nginx.org's nginx. Each one ended with nginx answering `Content-Encoding: zstd`. ## Find out which nginx you run Each module package is built against one exact nginx package, so the package to install depends on where your nginx came from: ```sh nginx -v apt-cache policy nginx # Debian, Ubuntu rpm -q nginx # RHEL and derivatives, Amazon Linux ``` nginx.org's packages have versions like `1.30.6-1~noble` on Debian and Ubuntu and end in `.ngx` on RPM systems, as in `nginx-1.30.6-1.el9.ngx`. Anything else is the distribution's own nginx. | System | nginx from | Package | |---|---|---| | Debian 12, 13; Ubuntu 22.04, 24.04, 26.04 | nginx.org | `nginx-module-mzstd` | | Debian 13, Ubuntu 26.04 | the distribution | `libnginx-mod-http-mzstd` | | RHEL, AlmaLinux, Rocky Linux, Oracle Linux 8–10; Amazon Linux 2023 | nginx.org | `nginx-module-mzstd` | | RHEL, AlmaLinux, Rocky Linux 10 | the distribution | `nginx-mod-mzstd` | All packages are built for amd64 and arm64. The module needs nginx 1.25.4 or newer, which rules out the nginx that Debian 12, Ubuntu 22.04 and 24.04, and RHEL 8 and 9 ship: on those systems, install nginx from nginx.org as shown below. ## Debian and Ubuntu with nginx.org's nginx If nginx is not from nginx.org yet, add its repository first. These are [nginx.org's instructions](https://nginx.org/en/linux_packages.html) for Ubuntu; on Debian, replace `ubuntu-keyring` with `debian-archive-keyring` and `ubuntu` in the URL with `debian`. ```sh sudo apt install curl gnupg2 ca-certificates lsb-release ubuntu-keyring curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \ | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \ https://nginx.org/packages/ubuntu `lsb_release -cs` nginx" \ | sudo tee /etc/apt/sources.list.d/nginx.list printf "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" \ | sudo tee /etc/apt/preferences.d/99nginx sudo apt update sudo apt install nginx ``` Then add the module repository and install the module: ```sh curl -1sLf 'https://dl.cloudsmith.io/public/m-compressors/ngx_mzstd/setup.deb.sh' | sudo -E bash sudo apt-get install nginx-module-mzstd ``` The setup script comes from [Cloudsmith](https://cloudsmith.io/~m-compressors/repos/), which hosts the repository. It adds the repository's signing key and an apt source; download it and read it first if you prefer. Like nginx.org's own `nginx-module-*` packages, this one installs the module without enabling it. Add this line to the very top of `/etc/nginx/nginx.conf`: ```nginx load_module modules/ngx_mzstd.so; ``` ## Debian 13 and Ubuntu 26.04 with their own nginx ```sh sudo apt-get install nginx curl -1sLf 'https://dl.cloudsmith.io/public/m-compressors/ngx_mzstd/setup.deb.sh' | sudo -E bash sudo apt-get install libnginx-mod-http-mzstd ``` As with Debian's own module packages, the module is enabled on installation through `/etc/nginx/modules-enabled/`, so there is no `load_module` line to add. ## RHEL, AlmaLinux, Rocky Linux and Amazon Linux with nginx.org's nginx If nginx is not from nginx.org yet, create `/etc/yum.repos.d/nginx.repo`: ```ini [nginx-stable] name=nginx stable repo baseurl=https://nginx.org/packages/centos/$releasever/$basearch/ gpgcheck=1 enabled=1 gpgkey=https://nginx.org/keys/nginx_signing.key module_hotfixes=true ``` On Amazon Linux 2023 the `baseurl` is `https://nginx.org/packages/amzn/2023/$basearch/`, and nginx.org also adds `priority=9`. Then install nginx and the module: ```sh sudo dnf install nginx curl -1sLf 'https://dl.cloudsmith.io/public/m-compressors/ngx_mzstd/setup.rpm.sh' | sudo -E bash sudo dnf install nginx-module-mzstd ``` dnf asks you to accept the repository's signing key. Add the `load_module` line to the top of `/etc/nginx/nginx.conf`: ```nginx load_module modules/ngx_mzstd.so; ``` ## RHEL 10 with its own nginx This covers the default AppStream nginx on RHEL 10, AlmaLinux 10 and Rocky Linux 10: ```sh sudo dnf install nginx curl -1sLf 'https://dl.cloudsmith.io/public/m-compressors/ngx_mzstd/setup.rpm.sh' | sudo -E bash sudo dnf install nginx-mod-mzstd ``` The package enables the module through `/usr/share/nginx/modules/mod-mzstd.conf`, which the stock `nginx.conf` includes. ## nginx.org mainline For nginx.org's mainline nginx, use the `ngx_mzstd-mainline` repository. The package name stays the same: ```sh curl -1sLf 'https://dl.cloudsmith.io/public/m-compressors/ngx_mzstd-mainline/setup.deb.sh' | sudo -E bash ``` Use `setup.rpm.sh` instead of `setup.deb.sh` on RPM systems. ## Turn compression on The module does nothing until compression is switched on. Put this in `/etc/nginx/conf.d/zstd.conf`, which every nginx above includes inside `http`: ```nginx zstd on; zstd_comp_level 3; zstd_types text/plain text/css application/javascript application/json image/svg+xml; ``` `text/html` is compressed by default and does not need to be listed. Check the configuration, restart nginx and ask for Zstandard: ```sh sudo nginx -t sudo systemctl restart nginx curl -sS -o /dev/null -D - -H 'Accept-Encoding: zstd' http://localhost/ | grep -i content-encoding ``` The answer is `Content-Encoding: zstd`. If you also run [ngx_mbrotli](/2026/install-ngx-mbrotli-packages/), a browser that accepts both gets Zstandard, in either `load_module` order. To keep large responses from blocking other requests, the module can compress on a thread pool with `zstd_thread_pool default;`. This needs nginx built with `--with-threads`, which `nginx -V` shows. The [ngx_mzstd README](https://github.com/M-Compressors/ngx_mzstd#benchmarks) has the numbers and the trade-offs, including the extra memory a pool needs. ## Upgrades The module package requires the exact nginx package it was built for, so nginx and the module are upgraded together. The version shows the pairing: nginx.org packages put nginx first, as in `1.30.6+0.2.3`, and the distribution packages put the module first, as in `0.2.3+1.26.3`. After an upgrade, **restart** nginx rather than reloading it. A reload keeps the master process, and the master keeps the old module loaded. ## Moving from tokers/zstd-nginx-module Existing `zstd*` directives keep working unchanged. The packages conflict with the old module's, such as `nginx-module-zstd`, so remove those first. Also delete any `load_module` lines that load the old `.so` files: the old and new modules have the same names, and nginx refuses to load a module name twice. Questions: Q: Which ngx_mzstd package do I need? A: With nginx from nginx.org, nginx-module-mzstd. With Debian’s or Ubuntu’s own nginx, libnginx-mod-http-mzstd. With the nginx of RHEL 10, AlmaLinux 10 or Rocky Linux 10, nginx-mod-mzstd. Q: Do I need a load_module line after installing the package? A: Only with nginx.org’s nginx: add load_module modules/ngx_mzstd.so; at the top of /etc/nginx/nginx.conf. The packages for the distributions’ own nginx install a configuration file that loads the module. Q: Which encoding wins when both Brotli and Zstandard modules are loaded? A: Zstandard. When a client accepts both, nginx answers with Content-Encoding: zstd, whatever order ngx_mbrotli and ngx_mzstd are loaded in. Q: Can I keep my tokers/zstd-nginx-module configuration? A: Yes. ngx_mzstd has the same directives and defaults. Remove the old module’s packages and load_module lines first: the packages conflict, and nginx refuses to load two modules with the same name. Q: Is reload enough after upgrading the module package? A: No. The nginx master process keeps the old .so loaded across a reload, so run sudo systemctl restart nginx. --- ## Installing Brotli for NGINX from packages: ngx_mbrotli URL: https://m-compressors.github.io/2026/install-ngx-mbrotli-packages/ Published: 2026-10-22 How to install the ngx_mbrotli Brotli module with apt or dnf on Debian, Ubuntu, RHEL, AlmaLinux, Rocky Linux and Amazon Linux, for nginx.org's nginx and for the distribution's own. Summary: - Prebuilt ngx_mbrotli packages exist for amd64 and arm64, for nginx.org stable and mainline and for the nginx shipped by Debian 13, Ubuntu 26.04 and RHEL 10. - With nginx.org’s nginx the package is nginx-module-mbrotli and needs a load_module line; the distribution packages enable themselves. - ngx_mbrotli is a drop-in replacement for google/ngx_brotli: the same directives, defaults and $brotli_ratio variable. - Each package is built against one exact nginx package, so the module and nginx are upgraded together. Restart nginx after an upgrade, not just reload. **ngx_mbrotli** adds Brotli compression to NGINX. It is a drop-in replacement for google/ngx_brotli, written in Rust on top of the [mbrotli](https://github.com/M-Compressors/mbrotli) encoder, and it can compress on NGINX thread pools. This post installs it from packages. If there is no package for your system, [build it from source](/2026/build-ngx-mbrotli-from-source/). Every command below was run on 22 October 2026 on fresh containers of Debian 13 and AlmaLinux 10 with their own nginx, and of Ubuntu 24.04, Rocky Linux 9 and Amazon Linux 2023 with nginx.org's nginx. Each one ended with nginx answering `Content-Encoding: br`. ## Find out which nginx you run Each module package is built against one exact nginx package, so the package to install depends on where your nginx came from: ```sh nginx -v apt-cache policy nginx # Debian, Ubuntu rpm -q nginx # RHEL and derivatives, Amazon Linux ``` nginx.org's packages have versions like `1.30.6-1~noble` on Debian and Ubuntu and end in `.ngx` on RPM systems, as in `nginx-1.30.6-1.el9.ngx`. Anything else is the distribution's own nginx. | System | nginx from | Package | |---|---|---| | Debian 12, 13; Ubuntu 22.04, 24.04, 26.04 | nginx.org | `nginx-module-mbrotli` | | Debian 13, Ubuntu 26.04 | the distribution | `libnginx-mod-http-mbrotli` | | RHEL, AlmaLinux, Rocky Linux, Oracle Linux 8–10; Amazon Linux 2023 | nginx.org | `nginx-module-mbrotli` | | RHEL, AlmaLinux, Rocky Linux 10 | the distribution | `nginx-mod-mbrotli` | All packages are built for amd64 and arm64. The module needs nginx 1.25.4 or newer, which rules out the nginx that Debian 12, Ubuntu 22.04 and 24.04, and RHEL 8 and 9 ship: on those systems, install nginx from nginx.org as shown below. ## Debian and Ubuntu with nginx.org's nginx If nginx is not from nginx.org yet, add its repository first. These are [nginx.org's instructions](https://nginx.org/en/linux_packages.html) for Ubuntu; on Debian, replace `ubuntu-keyring` with `debian-archive-keyring` and `ubuntu` in the URL with `debian`. ```sh sudo apt install curl gnupg2 ca-certificates lsb-release ubuntu-keyring curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \ | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \ https://nginx.org/packages/ubuntu `lsb_release -cs` nginx" \ | sudo tee /etc/apt/sources.list.d/nginx.list printf "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" \ | sudo tee /etc/apt/preferences.d/99nginx sudo apt update sudo apt install nginx ``` Then add the module repository and install the module: ```sh curl -1sLf 'https://dl.cloudsmith.io/public/m-compressors/ngx_mbrotli/setup.deb.sh' | sudo -E bash sudo apt-get install nginx-module-mbrotli ``` The setup script comes from [Cloudsmith](https://cloudsmith.io/~m-compressors/repos/), which hosts the repository. It adds the repository's signing key and an apt source; download it and read it first if you prefer. Like nginx.org's own `nginx-module-*` packages, this one installs the module without enabling it. Add this line to the very top of `/etc/nginx/nginx.conf`: ```nginx load_module modules/ngx_mbrotli.so; ``` ## Debian 13 and Ubuntu 26.04 with their own nginx ```sh sudo apt-get install nginx curl -1sLf 'https://dl.cloudsmith.io/public/m-compressors/ngx_mbrotli/setup.deb.sh' | sudo -E bash sudo apt-get install libnginx-mod-http-mbrotli ``` As with Debian's own module packages, the module is enabled on installation through `/etc/nginx/modules-enabled/`, so there is no `load_module` line to add. ## RHEL, AlmaLinux, Rocky Linux and Amazon Linux with nginx.org's nginx If nginx is not from nginx.org yet, create `/etc/yum.repos.d/nginx.repo`: ```ini [nginx-stable] name=nginx stable repo baseurl=https://nginx.org/packages/centos/$releasever/$basearch/ gpgcheck=1 enabled=1 gpgkey=https://nginx.org/keys/nginx_signing.key module_hotfixes=true ``` On Amazon Linux 2023 the `baseurl` is `https://nginx.org/packages/amzn/2023/$basearch/`, and nginx.org also adds `priority=9`. Then install nginx and the module: ```sh sudo dnf install nginx curl -1sLf 'https://dl.cloudsmith.io/public/m-compressors/ngx_mbrotli/setup.rpm.sh' | sudo -E bash sudo dnf install nginx-module-mbrotli ``` dnf asks you to accept the repository's signing key. Add the `load_module` line to the top of `/etc/nginx/nginx.conf`: ```nginx load_module modules/ngx_mbrotli.so; ``` ## RHEL 10 with its own nginx This covers the default AppStream nginx on RHEL 10, AlmaLinux 10 and Rocky Linux 10: ```sh sudo dnf install nginx curl -1sLf 'https://dl.cloudsmith.io/public/m-compressors/ngx_mbrotli/setup.rpm.sh' | sudo -E bash sudo dnf install nginx-mod-mbrotli ``` The package enables the module through `/usr/share/nginx/modules/mod-mbrotli.conf`, which the stock `nginx.conf` includes. ## nginx.org mainline For nginx.org's mainline nginx, use the `ngx_mbrotli-mainline` repository. The package name stays the same: ```sh curl -1sLf 'https://dl.cloudsmith.io/public/m-compressors/ngx_mbrotli-mainline/setup.deb.sh' | sudo -E bash ``` Use `setup.rpm.sh` instead of `setup.deb.sh` on RPM systems. ## Turn compression on The module does nothing until compression is switched on. Put this in `/etc/nginx/conf.d/brotli.conf`, which every nginx above includes inside `http`: ```nginx brotli on; brotli_comp_level 5; brotli_types text/plain text/css application/javascript application/json image/svg+xml; ``` `text/html` is compressed by default and does not need to be listed. Check the configuration, restart nginx and ask for Brotli: ```sh sudo nginx -t sudo systemctl restart nginx curl -sS -o /dev/null -D - -H 'Accept-Encoding: br' http://localhost/ | grep -i content-encoding ``` The answer is `Content-Encoding: br`. To keep large responses from blocking other requests, the module can compress on a thread pool with `brotli_thread_pool default;`. This needs nginx built with `--with-threads`, which `nginx -V` shows. The [ngx_mbrotli README](https://github.com/M-Compressors/ngx_mbrotli#benchmarks) has the numbers and the trade-offs, including the extra memory a pool needs. ## Upgrades The module package requires the exact nginx package it was built for, so nginx and the module are upgraded together. The version shows the pairing: nginx.org packages put nginx first, as in `1.30.6+0.4.5`, and the distribution packages put the module first, as in `0.4.5+1.26.3`. After an upgrade, **restart** nginx rather than reloading it. A reload keeps the master process, and the master keeps the old module loaded. ## Moving from google/ngx_brotli Existing `brotli*` directives keep working unchanged. The packages conflict with google/ngx_brotli's, such as `nginx-module-brotli` or Debian's `libnginx-mod-http-brotli-filter` and `libnginx-mod-http-brotli-static`, so remove those first. Also delete any `load_module` lines that load the old `.so` files: the old and new modules have the same names, and nginx refuses to load a module name twice. ## Zstandard too The sibling module ngx_mzstd adds Zstandard the same way: [install it from packages](/2026/install-ngx-mzstd-packages/). Questions: Q: Which ngx_mbrotli package do I need? A: With nginx from nginx.org, nginx-module-mbrotli. With Debian’s or Ubuntu’s own nginx, libnginx-mod-http-mbrotli. With the nginx of RHEL 10, AlmaLinux 10 or Rocky Linux 10, nginx-mod-mbrotli. Q: Do I need a load_module line after installing the package? A: Only with nginx.org’s nginx: add load_module modules/ngx_mbrotli.so; at the top of /etc/nginx/nginx.conf. The packages for the distributions’ own nginx install a configuration file that loads the module. Q: Can I keep my google/ngx_brotli configuration? A: Yes. ngx_mbrotli has the same directives, defaults and $brotli_ratio variable. Remove the google/ngx_brotli packages and their load_module lines first: the packages conflict, and nginx refuses to load two modules with the same name. Q: Is reload enough after upgrading the module package? A: No. The nginx master process keeps the old .so loaded across a reload, so run sudo systemctl restart nginx.