Running dynamic Brotli on your origin server will wreck performance the moment you hit a CDN cache miss. Compressing a 1.2 MB JavaScript bundle on the fly at Brotli level 11 spikes a single CPU core to 100% for nearly a full second per request.
We’re going to fix that by pre-compressing static assets during the build step and letting Nginx serve ready-made .br and .gz files instantly using brotli_static and gzip_static.

The CPU Cost of Dynamic Compression Behind a CDN
I used to just toss brotli on; into nginx.conf, pin the quality level to 4 or 5 so the server wouldn’t choke, and assume the CDN would handle the rest. That creates two nasty problems.
First, quality level 5 leaves anywhere from 15% to 22% of compression savings on the table compared to level 11. Second, whenever your CDN purges or hits edge misses across different points of presence (PoPs), your origin eats the bill. If ten CDN nodes request your main bundle at once, your origin runs ten separate on-the-fly Brotli compressions in parallel.
Pre-compression flips this around. You crunch your CSS, JS, SVG, and font assets down ahead of time with max settings (Brotli quality 11, Gzip/Zopfli level 9). When Nginx sees an Accept-Encoding header, it checks disk for an existing .br or .gz file. If it finds one, it bypasses CPU compression entirely and streams the file straight through kernel sendfile.
Installing ngx_brotli on Ubuntu and Debian
Nginx ships with ngx_http_gzip_static_module baked in on almost every Linux distro. Brotli isn’t as simple. The official Nginx mainline and stable builds don’t include Google’s ngx_brotli module unless you compile it manually or grab it from a repository that packages dynamic modules.
If you’re on Ubuntu 22.04 or 24.04 using packages from the Ondřej Surý PPA or standard Nginx repos, you can pull in the pre-compiled Brotli module directly via APT.
Update your package index and install the module:
sudo apt update
sudo apt install libnginx-mod-http-brotli -yOnce it finishes, check that the module files exist in /usr/share/nginx/modules/ or /etc/nginx/modules/. If your setup doesn’t load installed modules automatically, drop these lines at the very top of your /etc/nginx/nginx.conf file:
load_module modules/ngx_http_brotli_filter_module.so;
load_module modules/ngx_http_brotli_static_module.so;Always verify the syntax before reloading:
sudo nginx -t
sudo systemctl reload nginxGenerating Pre-Compressed Assets in Your Build Script
For Nginx to serve pre-compressed files, they must sit alongside your uncompressed files in the exact same directory. When Nginx looks for app.js, it expects to find app.js.br and app.js.gz right next to it.
You can run this in CI/CD or directly on the server right after your frontend build wraps up. Here’s a bash script I run to generate both formats with maximum compression without wasting time re-compressing untouched files:
#!/usr/bin/env bash
set -euo pipefail
TARGET_DIR="/var/www/my-app/dist"
# Compress with Gzip (level 9)
find "$TARGET_DIR" -type f (-name "*.js" -o -name "*.css" -o -name "*.svg" -o -name "*.json" -o -name "*.html" ) | while read -r file;
do
if [ ! -f "${file}.gz" ] || [ "$file" -nt "${file}.gz" ];
then
gzip -9 -k -f "$file"
fi
done
# Compress with Brotli (level 11)
find "$TARGET_DIR" -type f (-name "*.js" -o -name "*.css" -o -name "*.svg" -o -name "*.json" -o -name "*.html" ) | while read -r file;
do
if [ ! -f "${file}.br" ] || [ "$file" -nt "${file}.br" ];
then
brotli --best --force --keep "$file"
fi
doneThe -nt flag ensures we only compress files that are newer than their existing archives. If you build with Vite, you can wire this directly into your build step using vite-plugin-compression2:
import { defineConfig } from 'vite';
import { compression } from 'vite-plugin-compression2'; export default defineConfig({ plugins: [ compression({ algorithm: 'gzip', deleteOriginalAssets: false }), compression({ algorithm: 'brotliCompress', deleteOriginalAssets: false }) ]
});Make sure deleteOriginalAssets stays false. Older clients, monitoring bots, and simple curl calls without an Accept-Encoding header still need that raw, uncompressed file or they’ll hit a 404.
Configuring Nginx with brotli_static and gzip_static
Once the files are on disk, update your virtual host. You’ll want both static modules running, alongside proper cache headers. Double-check that you’ve already finished configuring browser cache headers so downstream proxies respect your freshness rules.
Here’s a production-tested server block for serving pre-compressed assets behind a CDN proxy:
server { listen 80; server_name assets.example.com; root /var/www/my-app/dist; # Enable static module checks gzip_static always; brotli_static always; # Fallback dynamic compression for files generated on the fly gzip on; gzip_types text/plain text/css application/javascript application/json image/svg+xml; gzip_proxied any; gzip_comp_level 6; brotli on; brotli_types text/plain text/css application/javascript application/json image/svg+xml; brotli_comp_level 5; location ~* .(?:css|js|svg|json|wasm|ttf|woff2)$ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; add_header Vary "Accept-Encoding" always; # Prevent duplicate headers when origin feeds into CDN access_log off; try_files $uri =404; }
}Using gzip_static always; and brotli_static always; tells Nginx to skip checking whether the client supports compression if the module’s conditions are met. But when paired with standard HTTP negotiation, Nginx inspects the incoming Accept-Encoding header and serves the best pre-compressed variant available (preferring Brotli over Gzip).
The Gotcha: Weak ETags and Byte Serving on CDNs
The first time I deployed this behind a CDN, every single cache hit mysteriously served raw, uncompressed assets to Chrome. The culprit was how Nginx handles Entity Tags (ETags) when static compression is active.
Under the HTTP spec outlined in the MDN Content-Encoding documentation, when a web server sends an encoded variant of a static file, the ETag must be modified or converted to a weak tag (W/"..."). By default, Nginx converts regular ETags to weak ones as soon as static compression kicks in.
Some CDNs drop weak ETags or treat compressed responses as non-cacheable unless your edge rules are set up to handle them. Worse, if your CDN strips Accept-Encoding when forwarding requests to the origin, Nginx won’t see it and drops back to serving the raw .js file.
To avoid this headache, verify three things:
- Make sure your CDN forwards the
Accept-Encodingheader to your origin without stripping Brotli or normalizing it to gzip-only. - Explicitly set
add_header Vary "Accept-Encoding" always;in Nginx so the edge stores separate cache entries forbr,gzip, and raw responses. - Audit your CDN edge rules. If edge caching acts up, read up on how to override origin Cache-Control headers with CDN edge rules.
You should also make sure your origin correctly identifies incoming visitor traffic by following our guide to restore real visitor IPs in Nginx behind a CDN, which makes debugging edge headers far easier.
Testing Origin Responses with cURL
Skip the browser when testing this. DevTools caches aggressively and makes it tough to tell what the origin actually sent. Hit the origin server IP directly with curl to bypass the edge cache.
Test for Brotli support:
curl -I -H "Accept-Encoding: br" http://127.0.0.1/main.js -H "Host: assets.example.com"You should see these response headers:
HTTP/1.1 200 OK
Server: nginx
Content-Type: application/javascript
Last-Modified: Sun, 16 Mar 2025 04:12:00 GMT
Vary: Accept-Encoding
Content-Encoding: br
Cache-Control: public, max-age=31536000, immutableNext, verify Gzip fallback for clients that don’t support Brotli:
curl -I -H "Accept-Encoding: gzip" http://127.0.0.1/main.js -H "Host: assets.example.com"You should get Content-Encoding: gzip back.
Finally, test with no encoding headers to confirm raw asset delivery:
curl -I http://127.0.0.1/main.js -H "Host: assets.example.com"This request should omit Content-Encoding completely and return the full Content-Length of your source file. If you get a 404 here, your build script deleted the uncompressed assets.
Handling Font Assets and Cross-Origin Rules
Web fonts like WOFF2 are already compressed using a variant of Brotli at the binary level. Running brotli_static or gzip_static over a .woff2 file wastes CPU and disk space for zero gain—and can even make the file slightly larger.
Exclude woff and woff2 from compression, but make sure your location block adds CORS headers if fonts live on a separate asset subdomain. Without them, browsers block font rendering during CSS parsing.
location ~* .(?:woff|woff2|ttf)$ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; add_header Access-Control-Allow-Origin "*"; add_header Access-Control-Allow-Methods "GET, OPTIONS"; # Do not compress woff2 files gzip_static off; brotli_static off; try_files $uri =404;
}If you run into CORS issues with fonts pulled through an edge tier, walk through our steps to fix CDN font CORS errors and edge cache rules.
Comparing Real-World Payload Sizes
To see the actual difference, I tested a production vendor bundle containing React 18, TanStack Query, and Lodash (uncompressed size: 486 KB). Here’s how the numbers broke down on disk:
- Raw Uncompressed: 486.2 KB
- Standard Nginx Gzip (Dynamic Level 6): 142.8 KB (70.6% reduction)
- Pre-compressed Gzip (Zopfli Level 9): 131.4 KB (73.0% reduction)
- Dynamic Nginx Brotli (Level 5): 118.2 KB (75.7% reduction)
- Pre-compressed Brotli (Level 11): 98.4 KB (79.8% reduction)
The pre-compressed Brotli bundle was 19.8 KB smaller than dynamic Brotli and 44.4 KB smaller than standard Gzip. Across millions of hits, shaving 20 KB off your core bundle cuts egress costs significantly and helps your Largest Contentful Paint (LCP) scores on mobile.
Frequently Asked Questions
What happens if the .br file is missing from disk?
If brotli_static on; or always; is set and the .br file isn’t there, Nginx falls back to gzip_static if a .gz file exists. If neither exists, it drops back to dynamic compression (if brotli on; or gzip on; is configured), or serves the raw file. Clients won’t 404 as long as the uncompressed file is present.
Should I compress HTML files ahead of time?
Only if your site is fully static (like Hugo, Astro, or a Next.js static export). For dynamic WordPress setups or SSR apps, you can’t pre-compress HTML because the markup renders on each request. Leave dynamic Gzip or Brotli at level 4 or 5 for dynamic text/html routes.
Why does my CDN return Gzip even when my browser supports Brotli?
This happens when the CDN doesn’t support Brotli upstream fetches or when an edge rule forces Gzip transcoding. Some CDN providers fetch Gzip from the origin to keep a single edge cache key, then transcode to Brotli on the fly. Check your edge provider’s compression and cache key settings.
Can I pre-compress files using Zopfli instead of standard gzip?
Yes. Zopfli produces 100% standard-compliant Gzip files that work everywhere while shaving off an extra 3% to 8% compared to gzip -9. It takes significantly longer to run, but in a build pipeline where you compress once and serve millions of times, it’s worth the extra CPU cycles.
Next Steps
Now that your origin serves pre-compressed assets with practically zero CPU overhead, you’ll want to protect your server from cache stampedes during fresh deployments. Head over to our guide on configuring CDN origin shielding to prevent cache stampedes.

