Letting Cloudflare serve Brotli-compressed data with nginx · ma.ttias.beLetting Cloudflare serve Brotli-compressed data with nginx
Mattias GeniarGoogle PageSpeed flagged a stylesheet on ohdear.app the other day: front.css, the bundle for its public-facing pages. That surprised me, because Cloudflare sits in front of ohdear.app and Cloudflare does Brotli, and a Brotli’d copy of that file is around 40 KB. Browsers were downloading just over 50 KB of gzip instead. So why wasn’t Brotli kicking in?<br>Turns out the browser wasn’t getting Brotli at all. It was getting gzip. The reason is a CDN behaviour that’s easy to miss: Cloudflare won’t re-compress something your origin already compressed.<br>Reproducing it with curl#<br>The quickest way to see what encoding you’re actually getting is to vary the Accept-Encoding request header and watch the content-encoding on the response. I wrote about testing gzip with curl years ago<br>; same trick, now with Brotli in the mix.<br>Here’s the same URL fetched two ways, real output against ohdear.app through Cloudflare:<br>$ url="https://ohdear.app/build/assets/front-B0EY205R.css"
# What a real Chrome sends. It offers br.<br>$ curl -s -o /tmp/f -D - -H 'Accept-Encoding: gzip, deflate, br, zstd' "$url" \<br>| grep -i content-encoding<br>content-encoding: gzip<br>$ wc -c 50088
# Offer ONLY brotli<br>$ curl -s -o /tmp/f -D - -H 'Accept-Encoding: br' "$url" | grep -i content-encoding<br>content-encoding: br<br>$ wc -c 44989
Look at that. A browser that offers br gets gzip. Strip gzip from what I offer and Brotli comes back, 5 KB smaller.<br>That contrast is the whole bug. If Cloudflare were doing the compressing, it would pick Brotli the moment a client offered it. It didn’t. It returned gzip while gzip was on the menu, which only makes sense if the gzip I normally get is the origin’s, not Cloudflare’s.<br>Why Cloudflare forwards gzip instead of Brotli’ing#<br>The origin is nginx on a Laravel Forge<br>box. Forge enables gzip globally, and its gzip_types includes text/css, so nginx gzips the stylesheet before it ever reaches Cloudflare.<br>Cloudflare, when you hand it a response that already carries content-encoding: gzip, does the sensible thing and passes it through. It will not decompress your gzip, throw it away, and re-encode it as Brotli<br>. Transcoding between compression formats on every request would be wasteful, so Cloudflare only applies its own Brotli when the origin sends the asset uncompressed.<br>So I had two compressors fighting over the same file, and the origin won. The fix is to pick one. Since Cloudflare is already there and does Brotli for free, I let it have the static assets.<br>The fix: stop the origin compressing immutable assets#<br>These are Vite build assets with content-hashed filenames (front-B0EY205R.css), served with Cache-Control: immutable. They never change, so there’s no reason for the origin to spend CPU gzipping them on the way to a CDN that wants them raw. One line in the build-assets location:<br>location ~* ^/build/assets/ {<br>gzip off;<br>add_header Cache-Control "public, max-age=31536000, immutable" always;<br>try_files $uri =404;
gzip off; in a location overrides the global gzip on; for just that path. Global gzip stays on for everything else (HTML and the like), and the immutable caching plus Vary: Accept-Encoding are untouched.<br>Validate and reload:<br>$ nginx -t<br>nginx: configuration file /etc/nginx/nginx.conf test is successful<br>$ systemctl reload nginx
Verifying the fix#<br>First, confirm the origin now sends raw. I hit nginx directly on the box, bypassing Cloudflare with --resolve:<br>$ curl -sk --resolve ohdear.app:443:127.0.0.1 -o /dev/null -D - \<br>-H 'Accept-Encoding: gzip' "https://ohdear.app/build/assets/front-B0EY205R.css" \<br>| grep -iE 'content-encoding|content-length'<br>content-length: 313583
No content-encoding. The origin now hands over 313 KB of raw CSS even when gzip is offered, which is exactly what I want Cloudflare to receive.<br>Then through Cloudflare, with a real browser’s Accept-Encoding. Cloudflare caches per encoding, so I added a cache-busting query string to force a fresh fetch from the now-raw origin instead of serving the stale gzip variant:<br>$ curl -s -o /tmp/f -D - -H 'Accept-Encoding: gzip, deflate, br, zstd' \<br>"https://ohdear.app/build/assets/front-B0EY205R.css?v=$(date +%s)" \<br>| grep -iE 'content-encoding|cf-cache-status'<br>cf-cache-status: MISS<br>content-encoding: br<br>$ wc -c 40196
content-encoding: br, 40 KB, down from 50. Re-fetch the same URL and cf-cache-status flips to HIT, still Brotli.<br>The full matrix, every variant coming off that one raw origin response:<br>Accept-Encoding sentCloudflare servesOn the wireidentity (no compression)uncompressed313,583 Bgzipgzip42,989 Bbrbrotli40,196 BYou don’t need any Cloudflare cache rules or page rules for this. Accept-Encoding is the one Vary header Cloudflare handles natively: it stores the asset and serves the right encoding per client, including plain gzip for the...