9.3 gzip Done Right
The 54 KB text file that becomes 240 bytes. No other directive in NGINX buys that ratio — and no other is misconfigured as often.
Intermediate
What you will learn
The gzip directive set — every line earning its place.
What compresses (and what never should):
gzip_typesdiscipline.The level ladder — 1 vs 6 vs 9, measured on a real fixture.
gzip_vary,gzip_min_length, and the caching interplay.The CPU budget: when compression is the wrong trade.
1. The directive set
The working block:
gzip on;
gzip_min_length 256; # don't bother under this many bytes
gzip_comp_level 6; # the CPU/size sweet spot
gzip_types text/plain text/css text/javascript application/json
application/javascript application/xml;
gzip_vary on; # announce negotiation to caches
gzip_proxied any; # compress proxied responses tooThe headline receipt — a 54 001-byte text fixture through this block:
$ curl -s -D - -o /dev/null -H 'Accept-Encoding: gzip' …/fixture.txt | grep -i content-encoding
Content-Encoding: gzip # gzipped when requested
$ curl -s --compressed -o /dev/null -w '%{size_download}\n' …/fixture.txt
240 # the 54 001-byte fixture, 225x smaller240 bytes. A 225× reduction — which is why this lesson exists before every other performance trick: no buffer tuning, no worker math, no kernel ticket comes close to deleting 99.5% of the bytes on the wire for text-shaped traffic.
2. What compresses — and what never should
gzip_types
defaults to
text/html
only — the single most common gzip misconfiguration is believing gzip is "on" while CSS, JS, and JSON travel uncompressed. This list is the production starter set; add your exotic text families (SVG! —
image/svg+xml) as they appear.
What stays out of the list, deliberately:
Type | Why not |
|---|---|
images (png/jpeg/webp) | already compressed — gzip burns CPU for ~1% and can even grow the file |
video/audio | same; containers have their own entropy coding |
| unknown binary — treat as compressed-by-protocol |
woff2 fonts | pre-compressed format (woff2 is the compression) |
archives (zip/gz/br) | obviously |
The teach-yourself test:
curl -s --compressed -o /dev/null -w '%{size_download}' vs. the raw size for each asset class you serve. Anything text-shaped with ratio ≈ 1 is a missing
gzip_types entry; anything binary with ratio ≈ 1 is correct.
gzip_proxied any deserves its line of explanation: proxied responses are
not compressed by default (a 1990s assumption that proxies mean dial-up modem chains);
any says "compress regardless of request provenance" — the modern default for your own backends.
3. The level ladder — measured
gzip_comp_level runs 1–9: fast-and-lazy to slow-and-thorough. Two A/B locations (
/gz1/,
/gz9/) over the same 54 KB fixture:
$ curl -s --compressed -o /dev/null -w '%{size_download}\n' …/gz1/
406 # level 1: the 54 001-byte fixture, 133x smaller
$ curl -s --compressed -o /dev/null -w '%{size_download}\n' …/gz9/
240 # level 9: 406 vs 240 bytes — thoroughness buys another ~40%Read the numbers like an engineer:
Level 1 already deletes 99.2%. The first level of DEFLATE does the heavy lifting (LZ77 matching); later levels spend exponentially more CPU hunting marginally better matches.
Level 9 bought 166 bytes (406 → 240) on this fixture. On production traffic with mixed content, level 9 typically costs 2–5× the CPU of level 6 for single-digit-percent size wins.
Level 6 is the consensus default for a reason; drop to 1 (or pre-compress — §5) when CPU is the scarce resource; consider 9 only for cold-cache, high-reuse static assets.
4. The interplay:
min_length,
Vary, and caches
gzip_min_length 256 — compression has fixed overhead (header ~18 bytes, plus the CPU): tiny bodies lose money. The receipt:
$ curl -s -D - -o /dev/null -H 'Accept-Encoding: gzip' …/tiny.txt | grep -i content-encoding
(no header) # 37 B < gzip_min_length 256 — sent as-isNote the subtlety: the directive checks the
Content-Length of the
response — which means
responses without a Content-Length are compressed regardless of
gzip_min_length
— the length check is skipped when there is nothing to measure — so chunked app responses gzips just fine (what the check can't do is protect you from money-losing tiny bodies of unknown length).
gzip_vary on adds
Vary: Accept-Encoding — the header that tells every cache (Module 8!) the response
differs by request header. Without it: a compressed copy can be served to a client that can't decode it (ancient clients, broken middleboxes) — or worse, an
uncompressed copy cached and served to everyone while you wonder why your ratio collapsed. With it, caches key the variants separately. Assert the header on every compressed response; Module 8's key-design lesson folds this in: your
proxy_cache_key needs no
Accept-Encoding term
because
Vary carries it.
5. The CPU budget and the escape hatches
Compression is CPU-for-bandwidth: at level 6, a core compresses roughly 20–100 MB/s. Three escape hatches when the trade goes wrong:
gzip_static(Module 3's compile flag--with-http_gzip_static_module): servefile.txt.gzfrom disk when it exists — pre-compressed at build time, level 9, zero runtime CPU. The static-asset answer.gunzipmodule (--with-http_gunzip_module): serve pre-compressed content even to clients that can't accept gzip — decompress on the fly for the rare legacy visitor.Scope the ladder:
gzip off;in CPU-bound locations (health checks! — the/healthzidiom), or per-server policies when one vhost serves hot JSON and another serves cold logs.
And the anti-pattern to refuse on principle: compressing inside TLS-terminating edges for responses that were already going to be re-compressed downstream — double compression is pure CPU arson.
Try it yourself
Reproduce every receipt in this lesson: the 240-byte headline, the tiny-file pass-through, the level ladder — with your own fixture (generate different text shapes: JSON, repeated lines, random-ish prose) and watch ratios vary by content.
Find the missing-type bug. Serve an SVG (drop one into
/var/www/), request with--compressed— ratio 1 (default types miss it). Addimage/svg+xmltogzip_types, reload, watch it compress. You have just fixed the internet's most common gzip bug.The level CPU bill. Time 500 compressions at level 1 vs 9 (
for i in $(seq 1 500); do curl -s --compressed .../gz9/compressible.txt > /dev/null; donewithtime) — feel the difference the fixture's 166 bytes bought.Vary in the cache context. Point a scratch
proxy_cachelocation at/compressible.txt, fetch compressed and uncompressed, and inspect your cache: one entry? two? (Answer involves Module 8's Vary handling — a deliberate preview.)
Q&A
Q1. gzip is on, but your API's JSON responses travel uncompressed. Three candidate causes, in order of likelihood?
Answer
(1)
application/json missing from
gzip_types (the default is html-only). (2) The response is proxied and
gzip_proxied is at its default
off — nginx refuses to compress proxied responses until you opt in (§2’s
gzip_proxied any). (3) The location/vhost inherits
gzip off somewhere up the ladder (Lesson 2.5 cuts both ways).
Q2. Why does everyone converge on level 6 when the NGINX default is a lazy 1 and the "best" is 9?
Answer
Diminishing returns: level 1 already captures most of DEFLATE's win; 9 costs multiples of CPU for single-digit-percent size gains. 6 sits at the knee of the curve — measurable on the 406-vs-240 receipt above against real clock time.
Q3. A cache serves your compressed API responses to a client that can't gunzip. What header was missing, and what does it do?
Answer
Vary: Accept-Encoding (
gzip_vary on) — it instructs caches to store/retrieve variants per request's Accept-Encoding, preventing cross-population serving. (Module 8's identity contract, carrying the negotiation dimension.)
Q4. Health checks at 2 000/s against a gzipped-everywhere server are eating CPU. Fix without un-gzipping the site?
Answer
location = /healthz { gzip off; return 200 "ok"; } — the exact-match hot path (Lesson 2.3) exempted from the compression ladder. Tiny bodies wouldn't compress anyway; the directive just skips the evaluation.
Takeaways
gzip is the highest-ratio performance line in NGINX (54 001 → 240 bytes on the fixture above) — if it's actually covering your types.
gzip_typesis where configs fail: html-only is the default; JSON, CSS, JS, SVG are your job. Binary and pre-compressed families stay out forever.Level 6 is the knee; level 1 already wins ~99%; level 9 is for pre-compressed static artifacts (
gzip_static), not hot paths.gzip_varyis the cache-safety header;gzip_min_lengthskips money-losing tiny bodies (and can't evaluate unknown lengths).CPU-for-bandwidth is the trade; scope it per location and buy it back with pre-compression when traffic justifies.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.