Brotli and Gzip Precompression Served by Nginx
Compression is the cheapest performance win on a static site: text files shrink by 70–85%, and nothing about the site changes. Most self-hosted Nginx setups enable gzip on and stop there, compressing every response on the fly at a moderate level. A static site can do better. Its files change only on deploy, so they can be compressed once at build time with Brotli at its maximum level, and Nginx can serve the precompressed file directly — smaller responses and zero compression CPU per request.
This guide adds build-time Brotli and gzip to an Eleventy documentation site served by Nginx 1.26 on a small VM, configures brotli_static and gzip_static, verifies content negotiation and caching headers, and measures transfer size and server CPU before and after. It belongs to Self-Hosting Static Sites on S3, Nginx and Caddy; the build-side compression choices, including Zstandard, are compared in Precompressing Assets with Brotli and Zstd.
Prerequisites
- Nginx with the
ngx_brotlimodule (brotli_staticdirective). On Debian and Ubuntu:apt install libnginx-mod-http-brotli-static libnginx-mod-http-brotli-filter. brotliandgzip(orzopfli) available in the build environment.- Shell access to the server to reload Nginx.
Step 1: Precompress at Build Time
After the Eleventy build, a script writes .br and .gz siblings for every compressible file:
find _site -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' -o -name '*.json' \
-o -name '*.svg' -o -name '*.xml' -o -name '*.txt' -o -name '*.webmanifest' \) -size +1k -print0 |
xargs -0 -P "$(nproc)" -I{} sh -c 'brotli -q 11 -k -f "{}" && gzip -9 -k -f -n "{}"'
-q 11is Brotli's highest level; it is slow, but runs once per build in parallel.-kkeeps the original, which Nginx serves to clients that accept neither encoding.-nomits the timestamp from gzip headers, so unchanged files produce identical.gzfiles and do not look changed to the deploy.- Files under 1 KB are skipped; the headers would outweigh the saving.
On 2,900 pages plus assets, precompression added 38 seconds to the build with 4 parallel jobs.
Step 2: Configure Nginx
http {
brotli_static on;
gzip_static on;
gzip_vary on;
# on-the-fly fallback for anything not precompressed (e.g. small files)
gzip on;
gzip_comp_level 5;
gzip_types text/css application/javascript application/json image/svg+xml application/xml text/plain;
brotli on;
brotli_comp_level 5;
brotli_types text/css application/javascript application/json image/svg+xml application/xml text/plain;
}
For each request, Nginx checks Accept-Encoding: if br is accepted and file.br exists, it serves that with Content-Encoding: br; otherwise, if gzip is accepted and file.gz exists, it serves that; otherwise the original. gzip_vary on adds Vary: Accept-Encoding, which tells caches between Nginx and the reader to store separate copies per encoding. The on-the-fly settings handle the small files the build skipped, at a moderate level.
Step 3: Verify Negotiation and Headers
for enc in 'br' 'gzip' 'identity'; do
curl -s -o /dev/null -D - -H "Accept-Encoding: $enc" https://docs.example.com/guides/install/ \
| grep -iE '^(content-encoding|content-length|vary|etag|cache-control)' | tr '\r\n' ' '; echo " <- $enc"
done
Expected: content-encoding: br with a small length for br, gzip for gzip, no encoding for identity, and vary: Accept-Encoding on all three. Each encoding has its own ETag, because each is a different file — correct behaviour, since the bytes differ.
One problem surfaced here: Content-Type on .br responses for SVG files was application/octet-stream. Nginx sets the type from the original file's extension when serving through brotli_static, but an old location ~ \.svg$ block in the config used try_files in a way that matched the .br file directly. Removing that block fixed it. Checking every file type once with curl is worth the minute.
Step 4: Keep Caching Headers Consistent
Precompressed files must carry the same Cache-Control as the originals. Because headers in Nginx are set by location, and the .br file is served for the original URI, the location that matches the original path applies — no extra configuration was needed. The asset policy from Cache-Control Headers for Static Sites in Nginx applied unchanged to the compressed variants.
Deploying the Extra Files
Precompression triples the number of text files in the output, which matters for the deploy step. The site deploys with rsync --checksum into a new release directory and switches a symlink, as in Zero-Downtime Deploys with Symlink Swaps. Because .br and .gz files for unchanged sources are byte-identical between builds (thanks to -n and Brotli's deterministic output), rsync skips them, and a typical content deploy transfers a few dozen files rather than thousands. Without -n, every .gz file changed on every build and deploys took four times as long.
The symlink switch also guarantees that an original and its compressed siblings always change together. A deploy that copied files in place one at a time would, for a moment, serve a new page.html alongside an old page.html.br — a reader receiving the Brotli version would see the old page. Release directories make that impossible.
When a CDN Sits in Front
Several sites on the same server later moved behind a CDN. Most CDNs compress on their own and request content from the origin with Accept-Encoding headers of their choosing. With precompressed files on the origin, the CDN receives Brotli directly when it asks for it, so the CDN's own compression step is skipped and the stored copy is the smallest possible. Check that the CDN forwards Accept-Encoding to the origin and varies its cache on it; some CDNs normalise the header to gzip only, in which case the origin's .gz files are served and Brotli is applied at the edge instead.
Measured Impact
Transfer sizes for representative files, and server CPU over a week at the same traffic:
| File | Original | gzip on-the-fly (level 1, before) | Brotli q11 precompressed |
|---|---|---|---|
| Docs page HTML | 61.4 KB | 15.9 KB | 10.8 KB |
| Main CSS | 48.2 KB | 11.3 KB | 8.4 KB |
| Search UI JS | 72.0 KB | 24.1 KB | 18.6 KB |
| Search index chunk (JSON) | 38.5 KB | 13.0 KB | 9.7 KB |
| Measure (one week) | Before | After | Tool |
|---|---|---|---|
| Bytes served (text types) | 182 GB | 129 GB | Nginx log $body_bytes_sent |
| Nginx worker CPU, peak hour | 31% | 9% | pidstat |
| TTFB p50, HTML (server-side time) | 4.1 ms | 1.2 ms | $request_time in logs |
| LCP p75, mobile docs | 1.9 s | 1.8 s | web-vitals RUM |
The CPU saving let the site stay on a 2-vCPU VM through a traffic spike that would previously have needed a bigger instance.
Pitfalls & Rollback
- No
Vary: Accept-Encoding. A cache may serve Brotli to a client that cannot decode it. - Compressing images and fonts. They are already compressed; skip them.
- Timestamps in gzip headers. Use
-nso unchanged files stay identical between builds. - Location blocks that match
.brfiles directly. They can serve the wrongContent-Type. - Rollback: set
brotli_static off; gzip_static off;and reload; Nginx falls back to on-the-fly compression immediately.
Conclusion
A static site served by Nginx should compress at build time, not per request. Writing .br and .gz files with maximum settings during the build and turning on brotli_static and gzip_static cut text transfer by 29%, cut Nginx CPU at peak from 31% to 9%, and took server-side time for HTML from 4.1 ms to 1.2 ms, at the cost of 38 seconds of build time.
FAQ
Why precompress instead of letting Nginx compress on the fly?
On-the-fly compression runs on every request, so it uses moderate levels to keep CPU and latency low. Precompressing at build time can use the maximum levels, which produce smaller files, and costs nothing per request. For a static site whose files only change on deploy, precompression is strictly better.
Does Nginx support Brotli out of the box?
No. gzip_static is part of standard Nginx builds, but Brotli needs the ngx_brotli module, which provides brotli_static and brotli directives. Many distributions package it, for example as libnginx-mod-http-brotli-static, and some Nginx builds include it already.
What happens if a browser does not accept Brotli?
Nginx checks the Accept-Encoding request header. With brotli_static on, it serves the .br file only when the client accepts br; otherwise gzip_static serves the .gz file when gzip is accepted, and the original file is served when neither is. All modern browsers accept Brotli over HTTPS.
Which files should be precompressed?
Text formats that compress well: HTML, CSS, JavaScript, JSON, SVG, XML and plain-text feeds. Skip already-compressed formats such as JPEG, PNG, WebP, AVIF, WOFF2 and video, where compression wastes CPU and can make files larger.
Related
- Parent: Self-Hosting Static Sites on S3, Nginx and Caddy — running your own static hosting.
- Precompressing Assets with Brotli and Zstd — the build-side choices.
- Serving a Static Site with Nginx — the base server configuration.
- Cache-Control Headers for Static Sites in Nginx — caching for the same files.
- Nginx vs Caddy for Serving Static Sites — how Caddy handles precompressed files.