---
title: "Serving Precompressed Brotli And Gzip From Nginx"
url: https://deallocatedobjects.com/posts/serving-precompressed-brotli-and-gzip-from-nginx
author: Jake Marsh
published: 2026-11-08
updated: 2026-11-08
topics: ["This Blog", "Web"]
description: "Every text file on Deallocated Objects is compressed before it reaches the server. The generator writes index.html, then writes index.html.br and index.html.gz right next to it, and nginx picks whichever one the browser asked for."
---

# Serving Precompressed Brotli And Gzip From Nginx

By Jake Marsh, November 8, 2026. HTML: https://deallocatedobjects.com/posts/serving-precompressed-brotli-and-gzip-from-nginx

Every text file on Deallocated Objects is compressed before it reaches the server. The generator writes `index.html`, then writes `index.html.br` and `index.html.gz` right next to it, and nginx picks whichever one the browser asked for. The homepage goes over the wire at about 14 KB instead of about 61 KB, and nginx does no compression work to get there.

### The Build Step

The last thing `src/build.ts` does is walk the output folder and compress the text files. Bun ships `node:zlib` with Brotli built in, so there's no extra package:

```ts title="src/build.ts"
const { brotliCompressSync, gzipSync, constants: Z } = await import("node:zlib");

// ---------- precompress (nginx gzip_static + brotli_static serve these) ----------
let compressed = 0;
(function walk(d: string) {
  for (const f of rd(d)) {
    const full = join(d, f);
    if (st(full).isDirectory()) { walk(full); continue; }
    if (!/\.(html|css|js|json|xml|atom|txt|md|svg|xsl|webmanifest)$/.test(f) || st(full).size < 1024) continue;
    const buf = rf(full);
    wf(`${full}.br`, brotliCompressSync(buf, { params: { [Z.BROTLI_PARAM_QUALITY]: 11, [Z.BROTLI_PARAM_SIZE_HINT]: buf.length } }));
    wf(`${full}.gz`, gzipSync(buf, { level: 9 }));
    compressed++;
  }
})(dist);
```

Images and fonts stay off the extension list because they're already compressed, and files under 1 KB aren't worth the trouble. The build deletes `dist/` before it writes anything and the deploy uses `rsync --delete`, so a `.br` can't go stale next to a newer page.

### Why Quality 11 Belongs At Build Time

Here's the JSON Feed (443,921 bytes) at a few settings, averaged over 10 runs. My Mac was busy when I timed these, so look at the ratios more than the milliseconds.

| Setting | Size | Time |
|---|---|---|
| gzip level 9 | 81,072 | 21 ms |
| Brotli quality 5 | 75,120 | 19 ms |
| Brotli quality 9 | 72,616 | 36 ms |
| Brotli quality 11 | 65,517 | 1,746 ms |

Quality 11 takes about 50 times the CPU of quality 9 to shave off another 10%. Nobody should pay that per request, but paying it once on my Mac at build time means every reader gets the smaller file.

### The Nginx Side

Ubuntu 24.04 packages Brotli as a dynamic module for its stock nginx, so `apt install libnginx-mod-http-brotli-static` and a reload is the whole setup. Then the vhost turns on both:

```nginx title="deploy/nginx.conf"
gzip_static on;
brotli_static on;
gzip_vary on;
```

If the request's `Accept-Encoding` includes `br` and a `.br` file exists, nginx sends that. Otherwise it tries the `.gz`, and then the plain file. This works through `try_files` too, so `/posts/<slug>` resolves to its `.html` and picks up the compressed files next to it. Because these are real files on disk, responses get a normal `Content-Length`, and `gzip_vary` adds the `Vary: Accept-Encoding` that keeps shared caches from handing Brotli bytes to a client that can't read them.

I keep the `.gz` files because browsers aren't the only readers. Feed readers, crawlers and scripts often ask only for gzip.

### One Gotcha With Proxies

While testing I noticed this:

```bash
$ curl -sI -H 'Accept-Encoding: gzip' -H 'Via: 1.1 proxy' https://deallocatedobjects.com/ | grep -i content-length
content-length: 60621
$ curl -sI -H 'Accept-Encoding: br' -H 'Via: 1.1 proxy' https://deallocatedobjects.com/ | grep -i content-length
content-length: 13945
```

`gzip_static` follows nginx's `gzip_proxied` setting, which defaults to `off`, so a request carrying a `Via` header gets the uncompressed file. The Brotli module doesn't check it. Nothing sits in front of this site, but if you put a CDN or proxy in front of nginx that adds `Via`, set `gzip_proxied any` or your gzip clients will quietly get full-size files.
