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_brotli module (brotli_static directive). On Debian and Ubuntu: apt install libnginx-mod-http-brotli-static libnginx-mod-http-brotli-filter.
  • brotli and gzip (or zopfli) 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 11 is Brotli's highest level; it is slow, but runs once per build in parallel.
  • -k keeps the original, which Nginx serves to clients that accept neither encoding.
  • -n omits the timestamp from gzip headers, so unchanged files produce identical .gz files 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.

Decision tree for how Nginx chooses a precompressed file The root asks what the request's Accept-Encoding header allows. If it includes br and a .br file exists, Nginx sends the Brotli file with Content-Encoding br. If it includes gzip and a .gz file exists, it sends the gzip file. If neither precompressed file exists but the type is compressible, it compresses on the fly at level 5. Otherwise it sends the original file. Which file Nginx sends What does Accept-Encoding allow? br, and file.br exists Send .br Content-Encoding: br gzip, and file.gz exists Send .gz Content-Encoding: gzip No precompressed file On the fly level 5 No compression accepted Original file uncompressed
All four branches add Vary Accept-Encoding so intermediate caches keep encodings apart.

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:

FileOriginalgzip on-the-fly (level 1, before)Brotli q11 precompressed
Docs page HTML61.4 KB15.9 KB10.8 KB
Main CSS48.2 KB11.3 KB8.4 KB
Search UI JS72.0 KB24.1 KB18.6 KB
Search index chunk (JSON)38.5 KB13.0 KB9.7 KB
Compressed transfer size with on-the-fly gzip and precompressed Brotli Paired bars in kilobytes. A docs page went from 15.9 with on-the-fly gzip to 10.8 with precompressed Brotli. Main CSS from 11.3 to 8.4. Search JavaScript from 24.1 to 18.6. A search index chunk from 13.0 to 9.7. Transfer size per file type gzip on the fly (level 1) Brotli q11 precompressed Docs page HTML 15.9 KB 10.8 KB Main CSS 11.3 KB 8.4 KB Search JS 24.1 KB 18.6 KB Search index chunk 13.0 KB 9.7 KB
The previous config used gzip_comp_level 1, the Nginx default; sizes from curl with each Accept-Encoding.
Measure (one week)BeforeAfterTool
Bytes served (text types)182 GB129 GBNginx log $body_bytes_sent
Nginx worker CPU, peak hour31%9%pidstat
TTFB p50, HTML (server-side time)4.1 ms1.2 ms$request_time in logs
LCP p75, mobile docs1.9 s1.8 sweb-vitals RUM
Nginx worker CPU usage at peak hour before and after precompression Vertical bars. Before, with on-the-fly gzip, peak-hour worker CPU was 31 percent on the 2-vCPU VM. After, with precompressed Brotli and gzip, it was 9 percent. Nginx worker CPU at peak hour 31% On-the-fly gzip 9% Precompressed
pidstat sampling during the busiest hour of each week; traffic was within 3 percent between the weeks.

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 -n so unchanged files stay identical between builds.
  • Location blocks that match .br files directly. They can serve the wrong Content-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.

Filed underHow-tosnginx