Skip to content
Rezha Julio
Go back

Adding Brotli Compression to Caddy

5 min read

Caddy supports gzip and zstd compression natively, but Brotli? You need a custom build for that. I was checking my response headers one day and realized my sites were only serving gzip. Brotli has been supported by over 97% of browsers for a while now, and it compresses text content better than gzip. Time to fix that.

Why Brotli

Brotli was developed by Google and open-sourced in 2015. It uses the same foundation as gzip (LZ77 + Huffman coding) but adds a pre-defined 120KB static dictionary of common web content like HTML tags, CSS properties, and JavaScript keywords. That dictionary is what gives it the edge for web assets specifically.

Cloudflare tested this against over 10,000 HTML, CSS, and JavaScript files. At maximum quality, Brotli produced files about 1.19x smaller than gzip. For files smaller than 1KB, the gap widened to 1.38x, mostly thanks to that dictionary. Ballpark for typical web content: 15-25% smaller than gzip.

The cost is compression speed. Brotli is slower to compress, which matters if you’re doing it on every request. For static sites like mine where files get compressed once and served many times, it doesn’t matter.

The problem

Caddy’s built-in encode directive only knows about gzip and zstd. If you add br to the directive without the right module, Caddy just ignores it. No error, no warning. Your config looks correct but nothing changes.

The missing piece is caddy-brotli by ueffel, a third-party module that adds Brotli to Caddy’s encode directive.

Building Caddy with Brotli

I run Caddy in Docker, so I needed to rebuild the image with xcaddy. I also took this opportunity to upgrade from Caddy 2.10 to 2.11.2. Here’s the updated Dockerfile:

Dockerfile
FROM caddy:2.11-builder AS builder
RUN xcaddy build v2.11.2 \
--with github.com/caddy-dns/porkbun \
--with github.com/caddy-dns/cloudflare \
--with pkg.jsn.cam/caddy-defender \
--with github.com/sablierapp/sablier/plugins/caddy \
--with github.com/gsmlg-dev/caddy-admin-ui@main \
--with github.com/ueffel/caddy-brotli
FROM caddy:2.11
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

The caddy:2.11-builder image comes with xcaddy pre-installed, so you just list whatever modules you need with --with flags. The final stage copies the custom binary into a clean Caddy image.

One thing to note: I initially used xcaddy build without pinning a version, which resolved to v2.11.1. Caddy v2.11.2 had been released only 17 hours prior. Pinning explicitly with xcaddy build v2.11.2 fixed that. If you care about getting the latest patch version, don’t rely on the builder image’s default.

The previous Dockerfile used FROM caddy:2.10-builder and FROM caddy:2.10, so this was a two-part change: adding the caddy-brotli module and upgrading the base images.

Updating the Caddyfile

With the module baked in, enabling Brotli is one word:

Caddyfile
www.rezhajul.io rezhajul.io {
import basic-header
import basic-static
encode zstd br gzip
handle {
root * /www/rezhajulio
file_server
}
}

The import basic-header and import basic-static are my own Caddy snippets for common security headers and static file caching rules. They’re defined elsewhere in the Caddyfile and reused across all site blocks.

The order in the encode directive sets the server’s preference. zstd first (best ratio, limited browser support), then br (great ratio, 97%+ browser support), then gzip as the universal fallback. Caddy will negotiate the best option based on the client’s Accept-Encoding header.

I updated every site block in my Caddyfile. That meant changing encode zstd gzip to encode zstd br gzip across around 20 site blocks covering all my domains and subdomains.

Build and deploy

Terminal window
docker compose build --no-cache
docker compose up -d

The --no-cache flag matters. Docker’s build cache can be aggressive with multi-stage builds, and you want xcaddy to actually pull the latest module versions and the pinned Caddy release. Without it, you might end up with a stale binary.

Verifying it works

A quick curl with the right header tells you everything:

Terminal window
curl -sI -H "Accept-Encoding: br" https://rezhajul.io | grep -i content-encoding
content-encoding: br

The full response headers also showed an etag with a -br suffix (dgvut3d1g54b1es2-br), which is Caddy’s way of indicating the encoding variant.

If you see gzip instead of br, the module probably didn’t get included in the build. You can check with caddy list-modules inside the container to confirm http.encoders.br is present.

I also verified the build was fresh:

Terminal window
docker exec caddy-caddy-1 caddy build-info | grep -i 'go\|version'
v2.11.2 h1:iOlpsSiSKqEW+SIXrcZsZ/NO74SzB/ycqqvAIEfIm64=
go go1.26.1

Caddy v2.11.2 on Go 1.26.1, which includes recent security patches.

Gotchas I ran into

I was testing fedi.my.id and saw no content-encoding header at all. Turns out that endpoint returns content-length: 0. There’s nothing to compress, so Caddy skips encoding entirely. Makes sense, but it confused me for a few minutes.

Another one: rezhajulio.id is reverse-proxied to an upstream service that already compresses its responses. Caddy won’t re-compress something that’s already compressed. If you’re proxying, check whether your backend already handles encoding before blaming the config.

And if you’re verifying with curl, remember that redirects don’t have bodies. status.pegelinux.top returned a 302, which obviously won’t have a content-encoding header. Use curl -sI -H "Accept-Encoding: br" -L to follow the redirect and check the final 200 response.

Was it worth it?

For a static blog with small pages, honestly? The file size difference is barely noticeable. But I run about 20 sites behind this Caddy instance, and some of them serve larger payloads where that 15-25% reduction over gzip actually matters.

The whole change was one line in the Dockerfile and one word added to each Caddyfile block. If you’re already running a custom Caddy build with xcaddy for DNS plugins or anything else, there’s no reason not to add it. If you’re on the stock Caddy image, you’ll need to switch to a custom build first, but you probably should be doing that anyway if you need any third-party modules.


Related Posts