⚠️ See the end of the article for an update and decide whether or not you need Zstandard.
I installed zstd support on nginx, which serves the page you’re reading right now. Here’s how I did it.
Contents
The new Zstandard in compression
You’re likely aware by now that textual data like HTML or Markdown can be served compressed via HTTP with gzip either live or from pre-made files. If your browser or client supports it, this means you get the data faster as the compression makes them smaller.
Brotli—often seen as br—was then developed at Google as a more efficient alternative to that old format, which made smaller compressed files, though at the cost of slower decompression and heavier CPU costs for high level compression.
That’s when Yann Collet and his team at Meta Platforms came up with Zstandard, now open-source. It offers compression comparable to Brotli, but also at three times the decompression speed. Joining gzip and br, zstd has recently gained baseline support by mainstream browsers.
Serving compression
For my static files in websites on my server, I like to build compressed “sibling” files alongside my static assets like HTML, CSS, and JavaScript files, and set up nginx to automatically serve them if they are available and if the browser requests them.
For example, if I write an index.html file, I can compress it with gzip to get index.html.gz. Then in nginx, all I have to do is to enable gzip_static in the config for that file to be served automatically:
gzip_static on;
The idea now is to also build .zst files and have nginx also support with Zstandard. Fortunately, we can compile and use the zstd-nginx-module for this to happen.
(Brotli also isn’t supported natively by nginx. For that, Google also provides their own module. More on that later.)
Building dynamic nginx modules
Compiling your own nginx modules sounds scary and complicated, but really, it’s not so bad.
Modules can be built as static, meaning they are embedded within the nginx binary, which requires nginx to be recompiled, or dynamic, which can be loaded like add-ons. There are likely speed benefits by making them static, but to keep things simple, I’m going with dynamic.
Basically, what needs to be done is:
- Get to know the version of nginx installed.
- Note what configure options were used the nginx binary you are using.
- Download the source code matching the version of nginx you have.
- Build the dynamic module against that source code.
- Install the built module files and load them via the nginx config.
In my case, I am running nginx 1.24.0 in Ubuntu Server 24.10. (A bit old, I know.)
sudo su - # Switch to root
cd # Go to home directory
mkdir nginx-modules # Make directory for our nginx modules work
cd nginx-modules
git clone https://github.com/tokers/zstd-nginx-module # Clone zstd module
nginx -v # Get the version number of nginx
wget http://nginx.org/download/nginx-1.24.0.tar.gz # Download matching source
tar -zxvf nginx-1.24.0.tar.gz # Decompress source code tarball
Now we have the source code for our module and nginx. Doing ls should show you the zstd-nginx-module and nginx-1.24.0 directories (or whatever version of nginx you have).
cd nginx-1.24.0
nginx -V > config.txt 2>&1 # Note configure options
You’ll find the configure options we need to use in config.txt. Take a moment here to open the file and see them. Here’s mine:
nginx version: nginx/1.24.0 (Ubuntu)
built with OpenSSL 3.0.13 30 Jan 2024
TLS SNI support enabled
configure arguments: --with-cc-opt='-g -O2 -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer -ffile-prefix-map=/build/nginx-gpkOWo/nginx-1.24.0=. -flto=auto -ffat-lto-objects -fstack-protector-strong -fstack-clash-protection -Wformat -Werror=format-security -fcf-protection -fdebug-prefix-map=/build/nginx-gpkOWo/nginx-1.24.0=/usr/src/nginx-1.24.0-2ubuntu7.6 -fPIC -Wdate-time -D_FORTIFY_SOURCE=3' --with-ld-opt='-Wl,-Bsymbolic-functions -flto=auto -ffat-lto-objects -Wl,-z,relro -Wl,-z,now -fPIC' --prefix=/usr/share/nginx --conf-path=/etc/nginx/nginx.conf --http-log-path=/var/log/nginx/access.log --error-log-path=stderr --lock-path=/var/lock/nginx.lock --pid-path=/run/nginx.pid --modules-path=/usr/lib/nginx/modules --http-client-body-temp-path=/var/lib/nginx/body --http-fastcgi-temp-path=/var/lib/nginx/fastcgi --http-proxy-temp-path=/var/lib/nginx/proxy --http-scgi-temp-path=/var/lib/nginx/scgi --http-uwsgi-temp-path=/var/lib/nginx/uwsgi --with-compat --with-debug --with-pcre-jit --with-http_ssl_module --with-http_stub_status_module --with-http_realip_module --with-http_auth_request_module --with-http_v2_module --with-http_dav_module --with-http_slice_module --with-threads --with-http_addition_module --with-http_flv_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_mp4_module --with-http_random_index_module --with-http_secure_link_module --with-http_sub_module --with-mail_ssl_module --with-stream_ssl_module --with-stream_ssl_preread_module --with-stream_realip_module --with-http_geoip_module=dynamic --with-http_image_filter_module=dynamic --with-http_perl_module=dynamic --with-http_xslt_module=dynamic --with-mail=dynamic --with-stream=dynamic --with-stream_geoip_module=dynamic
You will need all the arguments displayed after configure arguments to configure our nginx source code, as well as an extra argument to point to the directory of our zstd module source code and to make the module dynamic.
There are a few arguments that need to be filtered out. Notably those mentioning a /build directory. Those were likely used during the Ubuntu package build process, and you don’t have those.
Then run ./configure in it in the nginx source code directory. Note the --add-dynamic-module=../zstd-nginx-module argument I added and the ones with values containing /build I removed:
./configure --with-cc-opt='-g -O2 -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer -flto=auto -ffat-lto-objects -fstack-protector-strong -fstack-clash-protection -Wformat -Werror=format-security -fcf-protection -fPIC -Wdate-time -D_FORTIFY_SOURCE=3' --with-ld-opt='-Wl,-Bsymbolic-functions -flto=auto -ffat-lto-objects -Wl,-z,relro -Wl,-z,now -fPIC' --prefix=/usr/share/nginx --conf-path=/etc/nginx/nginx.conf --http-log-path=/var/log/nginx/access.log --error-log-path=stderr --lock-path=/var/lock/nginx.lock --pid-path=/run/nginx.pid --modules-path=/usr/lib/nginx/modules --http-client-body-temp-path=/var/lib/nginx/body --http-fastcgi-temp-path=/var/lib/nginx/fastcgi --http-proxy-temp-path=/var/lib/nginx/proxy --http-scgi-temp-path=/var/lib/nginx/scgi --http-uwsgi-temp-path=/var/lib/nginx/uwsgi --with-compat --with-debug --with-pcre-jit --with-http_ssl_module --with-http_stub_status_module --with-http_realip_module --with-http_auth_request_module --with-http_v2_module --with-http_dav_module --with-http_slice_module --with-threads --with-http_addition_module --with-http_flv_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_mp4_module --with-http_random_index_module --with-http_secure_link_module --with-http_sub_module --with-mail_ssl_module --with-stream_ssl_module --with-stream_ssl_preread_module --with-stream_realip_module --with-http_geoip_module=dynamic --with-http_image_filter_module=dynamic --with-http_perl_module=dynamic --with-http_xslt_module=dynamic --with-mail=dynamic --with-stream=dynamic --with-stream_geoip_module=dynamic --add-dynamic-module=../zstd-nginx-module
Give it some time. Some things can go wrong. For example:
- Maybe some arguments aren’t necessary and you can remove them too.
- Maybe some arguments require some dev files to be installed. For example,
--with-pcre-jitrequires the apt packagepcre-dev.
It’s a bit of a trial and error, but shouldn’t take so long.
Once it’s done, we can go ahead. Instead of building all of nginx, just build its modules, including the dynamic ones:
make modules # Build modules
If everything goes well, you should have the module files built in a few minutes in the objs directory:
❯ find objs -iname '*zstd*.so'
objs/ngx_http_zstd_static_module.so
objs/ngx_http_zstd_filter_module.so
Installing the modules
Let’s copy the module files in /usr/local.
Why that directory? Because the Filesystem Hierarchy Standard states in 4.9. /usr/local: Local hierarchy:
The
/usr/localhierarchy is for use by the system administrator when installing software locally.
So, there. That directory is already there for that purpose. Why complicate things?
mkdir -p /usr/local/lib/nginx/modules # Make that directory if it doesn't exist
cp objs/*zstd*.so /usr/local/lib/nginx/modules # Copy the files
Then let’s make an include file for nginx to load our modules:
mkdir -p /usr/local/share/nginx/modules-available
cat << EOF > /usr/local/share/nginx/modules-available/mod-http-zstd.conf
load_module /usr/local/share/nginx/modules/ngx_http_zstd_filter_module.so;
load_module /usr/local/share/nginx/modules/ngx_http_zstd_static_module.so;
EOF
ln -s /usr/local/share/nginx/modules-available/mod-http-zstd.conf /etc/nginx/modules-enabled/90-mod-http-zstd.conf
⚠️ Note the above is ideal for the nginx build by Ubuntu. If you use another build or distro, you may have to add those load_module lines at a different location. If all fails, you may want to try simply adding them in /etc/nginx.conf.
To serve .zst sibling files automatically, we also need to enable zstd_static. Similar to gzip_static, that setting can be in the http, server, or location block depending if you want that enabled for the entire server, a specific site, or location, respectively. In the block of your choice, add the line:
zstd_static on;
Then we check the config, and if it’s good, reload nginx:
nginx -t && nginx -s reload
If there are problems with your config, nginx will tell you and the reload command will not run.
In Ubuntu, of course, you may reload with systemctl as well:
systemctl reload nginx
systemctl status nginx # Check the status of nginx after reload
⚠️ The most common problem at this point is nginx reporting the module file is not in the correct binary format. That means the configure arguments we used earlier were not correct. You’ll need to check them again and rebuild the module.
Now, if you have no problems, the modules should now be installed and ready to be used.
Test it out!
Let’s try zstd_static by compressing a file first. If you don’t have it yet, install zstd.
I’m assuming you already have a static site set up in nginx. Take its index.html file and compress it:
zstd --ultra -22 index.html # Compress file in index.html.zst
💡 While zstd has an argument to keep the original file after compression, like gz and br, instead of those two, -k is the default behaviour.
✨ If you have no files for your site written yet, you may use my litesite tool to quickly build a HTML directory, build it, and have the files compressed with zstd.
Host the new index.html.zst on your server and try to get it with a browser or cURL. Like in my case with my site:
❯ curl -I -H "Accept-Encoding: zstd" https://remino.net/ HTTP/2 200 date: Tue, 12 May 2026 12:22:10 GMT content-type: text/html; charset=utf-8 content-length: 2052 last-modified: Tue, 12 May 2026 12:00:40 GMT etag: "6a031668-804" content-encoding: zstd …
You may also download the file with cURL, decompress it, and diff it, for comparison:
❯ curl -sH "Accept-Encoding: zstd" https://remino.net/ > index.html.zst ❯ curl -s https://remino.net/ > index-plain.html ❯ zstd -d index.html.zst index.html.zst : 4448 bytes ❯ diff index.html index-plain.html ❯ echo $? # "0" means "no difference" 0
👍 Okay! If it downloads, decompresses, and there is no difference with the original, then it works!
Compressing live
The _static commands are for serving sibling compressed files automatically.
The compression modules also have commands like gzip on;, zstd on; and brotli on; to compress your server’s responses on-the-fly.
Personally, I don’t need them. But those might be something you may want to look into when your output is dynamic. If you do, you may also weigh in on whether or not live compression is worth it for your kind of web app or not.
The new order
The Accept-Encoding header field can specify multiple compression formats. As browsers support most common ones, you will notice in their console they send a header like Accept-Encoding: gzip, deflate, br, zstd when requesting files like HTML or JavaScript.
This normally doesn’t dictate the priority of the format. Also the order of the _static directives in nginx doesn’t seem to have much effect either.
For example, I support all common formats, yet zstd seems to always be the one served when available and requested, no matter how I order my directives or the request header.
The header should also work with quality values, or q=, similar to Accept-Language. But in practice, the support for those is patchy. In my case, I tried a few variants and the results were unpredictable:
❯ curl -s -I -H "Accept-Encoding: br;q=1, zstd" https://remino.net/ | grep content-encoding content-encoding: zstd ❯ curl -s -I -H "Accept-Encoding: br;q=0.5, zstd;q=1" https://remino.net/ | grep content-encoding content-encoding: br ❯ curl -s -I -H "Accept-Encoding: br;q=0.5, zstd" https://remino.net/ | grep content-encoding content-encoding: zstd ❯ curl -s -I -H "Accept-Encoding: gzip;q=1, br;q=0.5, zstd" https://remino.net/ | grep content-encoding content-encoding: zstd ❯ curl -s -I -H "Accept-Encoding: gzip;q=1, br;q=0.5" https://remino.net/ | grep content-encoding content-encoding: br
In order, I was expecting zstd, zstd, zstd, gzip, and gzip. But as you can see, that’s not the case. Brotli seems to be more aggressive and zstd seems to give up the moment there’s a quality value. Parsing of those values appears to depend on the modules and not on nginx itself.
Not a huge problem—just something to keep in mind if that matters to you or whatever special client you may be using. Notice the mainstream browsers don’t set q=.
Bonus: Brotli support
Google provides their own brotli module for nginx. Like gzip_static and zstd_static, it also provides a brotli_static directive to work with .br files.
For details, check their README file.
Anyway, that’s it! Congrats on setting your nginx’s new standard with Zstandard! 🎉
2026-06-03: Update
One of the designers of Brotli, Jyrki Alakuijala, pointed out to me earlier:
Compression rates are about 5% better for brotli.
For very large files, maxed out brotli and lzma2 are within 0.6 % of each other (lzma2 better), and zstd comes far behind. this is because no context modeling in zstd.
For short files, brotli wins.
[…]
In some cases — such as transfer over the internet — the compression density matters the most. The transmission can take 500 ms, where the decompression is done in 700 us. Then 5 % of 500 ms is 25 ms of saving. 2x savings on decompression speed is just 350 us.
It is not just for fun or principle, for a big internet service 25 ms can mean annual income difference of 250 million or so.
At first, I thought some troll was looking for a fight—I get a few of those on social media. But in this case, the comment came from one of Brotli’s designers, so it’s safe to assume he knows more about compression than I do. Consider your use case before preferring Zstandard over Brotli and going through all this setup.
