Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

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.

Levels: Intermediate ~35 min read

Intermediate

What you will learn

  1. The gzip directive set — every line earning its place.

  2. What compresses (and what never should): gzip_types discipline.

  3. The level ladder — 1 vs 6 vs 9, measured on a real fixture.

  4. gzip_vary, gzip_min_length, and the caching interplay.

  5. The CPU budget: when compression is the wrong trade.


1. The directive set

The working block:

nginx
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 too

The headline receipt — a 54 001-byte text fixture through this block:

text
$ 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 smaller

240 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

application/octet-stream

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:

text
$ 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:

text
$ 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-is

Note 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:

  1. gzip_static (Module 3's compile flag --with-http_gzip_static_module): serve file.txt.gz from disk when it exists — pre-compressed at build time, level 9, zero runtime CPU. The static-asset answer.

  2. gunzip module ( --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.

  3. Scope the ladder: gzip off; in CPU-bound locations (health checks! — the /healthz idiom), 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

  1. 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.

  2. Find the missing-type bug. Serve an SVG (drop one into /var/www/), request with --compressed — ratio 1 (default types miss it). Add image/svg+xml to gzip_types, reload, watch it compress. You have just fixed the internet's most common gzip bug.

  3. 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; done with time) — feel the difference the fixture's 166 bytes bought.

  4. Vary in the cache context. Point a scratch proxy_cache location 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_types is 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_vary is the cache-safety header; gzip_min_length skips 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.

Was this page helpful?

Mark this learning resource complete to track your learning progress.