Google 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?
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.
Reproducing it with curl#
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
; same trick, now with Brotli in the mix.
Here’s the same URL fetched two ways, real output against ohdear.app through Cloudflare:
$ url="https://ohdear.app/build/assets/front-B0EY205R.css"
# What a real Chrome sends. It offers br.
$ curl -s -o /tmp/f -D - -H 'Accept-Encoding: gzip, deflate, br, zstd' "$url" \
| grep -i content-encoding
content-encoding: gzip
$ wc -c < /tmp/f
50088
# Offer ONLY brotli
$ curl -s -o /tmp/f -D - -H 'Accept-Encoding: br' "$url" | grep -i content-encoding
content-encoding: br
$ wc -c < /tmp/f
44989
Look at that. A browser that offers br gets gzip. Strip gzip from what I offer and Brotli comes back, 5 KB smaller.
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.
Why Cloudflare forwards gzip instead of Brotli’ing#
The origin is nginx on a Laravel Forge
box. Forge enables gzip globally, and its gzip_types includes text/css, so nginx gzips the stylesheet before it ever reaches Cloudflare.
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
. Transcoding between compression formats on every request would be wasteful, so Cloudflare only applies its own Brotli when the origin sends the asset uncompressed.
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.
The fix: stop the origin compressing immutable assets#
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:
location ~* ^/build/assets/ {
gzip off;
add_header Cache-Control "public, max-age=31536000, immutable" always;
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.
Validate and reload:
$ nginx -t
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ systemctl reload nginx
Verifying the fix#
First, confirm the origin now sends raw. I hit nginx directly on the box, bypassing Cloudflare with --resolve:
$ curl -sk --resolve ohdear.app:443:127.0.0.1 -o /dev/null -D - \
-H 'Accept-Encoding: gzip' "https://ohdear.app/build/assets/front-B0EY205R.css" \
| grep -iE 'content-encoding|content-length'
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.
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:
$ curl -s -o /tmp/f -D - -H 'Accept-Encoding: gzip, deflate, br, zstd' \
"https://ohdear.app/build/assets/front-B0EY205R.css?v=$(date +%s)" \
| grep -iE 'content-encoding|cf-cache-status'
cf-cache-status: MISS
content-encoding: br
$ wc -c < /tmp/f
40196
content-encoding: br, 40 KB, down from 50. Re-fetch the same URL and cf-cache-status flips to HIT, still Brotli.
The full matrix, every variant coming off that one raw origin response:
| Accept-Encoding sent | Cloudflare serves | On the wire |
|---|---|---|
identity (no compression) | uncompressed | 313,583 B |
gzip | gzip | 42,989 B |
br | brotli | 40,196 B |
You 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 browsers that still don’t speak Brotli. The only thing that breaks that is an origin that pre-compresses, which is exactly what I’d accidentally been doing.
One honest caveat on the numbers: Cloudflare’s edge Brotli level isn’t fixed. I saw the same file land anywhere between 40 and 45 KB across fetches. Either way it beats the 50 KB gzip that PageSpeed flagged.
Checking the nginx half in a container#
I didn’t want to trust my memory of how a location’s gzip off interacts with a global gzip on, so I reproduced just the nginx part in a throwaway container before touching production:
$ docker run -d --rm -p 8090:80 \
-v "$PWD/gzip.conf":/etc/nginx/conf.d/gzip.conf:ro \
-v "$PWD/default.conf":/etc/nginx/conf.d/default.conf:ro \
-v "$PWD/html":/usr/share/nginx/html:ro nginx:alpine
# Same CSS file, two locations. One inherits the global gzip, one has gzip off.
$ curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' \
http://127.0.0.1:8090/static/front.css | grep -i content-encoding
content-encoding: gzip
$ curl -s -o /dev/null -D - -H 'Accept-Encoding: gzip' \
http://127.0.0.1:8090/build/assets/front.css | grep -i content-length
content-length: 278893
The normal location gzips, the gzip off location serves raw. Tested on nginx 1.31.1 from nginx:alpine; production is Forge’s nginx 1.31.0.
What about Brotli at the origin?#
You can do it. Compile the ngx_brotli module into nginx and serve pre-Brotli’d .br files with brotli_static on;, and a maxed-out brotli -q11 at the origin compresses harder than Cloudflare’s edge level does (which gave me 40 to 45 KB here). The catch on a Forge box is that the module is a dynamic .so tied to the exact nginx version. Every nginx package bump risks an ABI mismatch that stops nginx from starting, and Forge updates nginx out from under you. For a box where Cloudflare already does Brotli for free, I’d rather keep the origin dumb than sign up for that maintenance.
If you’ve got a CDN in front of your origin, that’s the rule worth remembering: let one of them compress, not both. I kept gzip on at the origin for HTML and handed the static assets to Cloudflare raw, and now the bytes on the wire are the ones I expected.