# Adding zstd support to nginx

> **⚠️ See the end of the article for an [update](#2026-06-03-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.

## 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](https://github.com/google/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](https://github.com/Cyan4973) and his team at Meta
Platforms came up with [Zstandard](https://facebook.github.io/zstd/), 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](https://caniuse.com/zstd).

## 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`](https://nginx.org/en/docs/http/ngx_http_gzip_static_module.html#gzip_static)
in the config for that file to be served automatically:

```nginx
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](https://github.com/tokers/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.)

```sh
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).

```sh
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:

```sh
./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-jit` requires the apt package `pcre-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:

```sh
make modules # Build modules
```

If everything goes well, you should have the module files built in a few minutes
in the `objs` directory:

<pre>
<span style="color: #0100FF">❯</span> find objs -iname '*zstd*.so'
objs/ngx_http_zstd_static_module.so
objs/ngx_http_zstd_filter_module.so
</pre>

## Installing the modules

Let's copy the module files in `/usr/local`.

Why that directory? Because the
[Filesystem Hierarchy Standard](https://refspecs.linuxfoundation.org/fhs.shtml)
states in
[4.9. `/usr/local`: Local hierarchy](https://refspecs.linuxfoundation.org/FHS_3.0/fhs/ch04s09.html):

> The `/usr/local` hierarchy is for use by the system administrator when
> installing software locally.

So, there. That directory is already there for that purpose. Why complicate
things?

```sh
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:

```sh
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:

```nginx
zstd_static on;
```

Then we check the config, and if it's good, reload nginx:

```sh
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:

```sh
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:

```sh
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](https://manpages.debian.org/buster/zstd/zstd.1.en.html#:~:text=compression%20or%20decompression-,%2Dk,-%2C%20%2D%2Dkeep).

✨ If you have no files for your site written yet, you may use my
[`litesite`](https://github.com/remino/remutils/tree/main/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:

<pre>
<span style="color: #0100FF">❯</span> <span style="color: #00aa00">curl</span> -I -H <mark><span style="color: #aa5500">"Accept-Encoding: zstd"</span></mark> https://remino.net/
HTTP/2 200 
<span style="font-weight: bold">date</span>: Tue, 12 May 2026 12:22:10 GMT
<span style="font-weight: bold">content-type</span>: text/html; charset=utf-8
<span style="font-weight: bold">content-length</span>: 2052
<span style="font-weight: bold">last-modified</span>: Tue, 12 May 2026 12:00:40 GMT
<span style="font-weight: bold">etag</span>: "6a031668-804"
<mark><span style="font-weight: bold">content-encoding</span>: zstd</mark>
&hellip;
</pre>

You may also download the file with cURL, decompress it, and diff it, for
comparison:

<pre>
<span style="color: #0100FF">❯</span> <span style="color: #00aa00">curl</span> -sH <span style="color: #aa5500">"Accept-Encoding: zstd"</span> https://remino.net/ <span style="color: #aa5500">&gt;</span> index.html.zst
<span style="color: #0100FF">❯</span> <span style="color: #00aa00">curl</span> -s https://remino.net/ <span style="color: #aa5500">&gt;</span> index-plain.html
<span style="color: #0100FF">❯</span> <span style="color: #00aa00">zstd</span> -d <span style="text-decoration: underline">index.html.zst</span> 
index.html.zst      : 4448 bytes                                               
<span style="color: #0100FF">❯</span> <span style="color: #00aa00">diff</span> <span style="text-decoration: underline">index.html</span> <span style="text-decoration: underline">index-plain.html</span>
<span style="color: #0100FF">❯</span> <span style="color: #00aa00">echo</span> $? <span style="color: #999"># "0" means "no difference"</span>
<mark>0</mark>
</pre>

👍 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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding)
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`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Language).
But in practice, the support for those is patchy. In my case, I tried a few
variants and the results were unpredictable:

<pre>
<span style="color: #0100FF">❯</span> <span style="color: #00aa00">curl</span> -s -I -H <span style="color: #aa5500">"Accept-Encoding: br;q=1, zstd"</span> https://remino.net/ | <span style="color: #00aa00">grep</span> content-encoding
<span style="font-weight: bold; color: #aa0000">content-encoding</span>: zstd
<span style="color: #0100FF">❯</span> <span style="color: #00aa00">curl</span> -s -I -H <span style="color: #aa5500">"Accept-Encoding: br;q=0.5, zstd;q=1"</span> https://remino.net/ | <span style="color: #00aa00">grep</span> content-encoding
<span style="font-weight: bold; color: #aa0000">content-encoding</span>: br
<span style="color: #0100FF">❯</span> <span style="color: #00aa00">curl</span> -s -I -H <span style="color: #aa5500">"Accept-Encoding: br;q=0.5, zstd"</span> https://remino.net/ | <span style="color: #00aa00">grep</span> content-encoding 
<span style="font-weight: bold; color: #aa0000">content-encoding</span>: zstd
<span style="color: #0100FF">❯</span> <span style="color: #00aa00">curl</span> -s -I -H <span style="color: #aa5500">"Accept-Encoding: gzip;q=1, br;q=0.5, zstd"</span> https://remino.net/ | <span style="color: #00aa00">grep</span> content-encoding
<span style="font-weight: bold; color: #aa0000">content-encoding</span>: zstd
<span style="color: #0100FF">❯</span> <span style="color: #00aa00">curl</span> -s -I -H <span style="color: #aa5500">"Accept-Encoding: gzip;q=1, br;q=0.5"</span> https://remino.net/ | <span style="color: #00aa00">grep</span> content-encoding 
<span style="font-weight: bold; color: #aa0000">content-encoding</span>: br
</pre>

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](https://github.com/google/ngx_brotli). Like
`gzip_static` and `zstd_static`, it also provides a `brotli_static` directive to
work with `.br` files.

For details, check their
[`README`](https://github.com/google/ngx_brotli/blob/master/README.md) 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](https://x.com/jyzg),
[pointed out to me earlier](https://x.com/jyzg/status/2062110531402486144):

> 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.
