gzip is a response content coding: nginx can send a compressed representation that an accepting client decodes. Enabling it requires gzip on; gzip_types extends the eligible MIME types but does not activate compression by itself. The result depends on the response, request capabilities, and effective configuration. A smaller body is useful only if it still decodes to the intended content.
When it breaks
A MIME list beneath gzip off changes no response into gzip. Another common omission is gzip_vary on. nginx does not unconditionally add Vary: Accept-Encoding merely because compression occurred; the directive controls that behavior. A shared cache selecting representations needs the relevant request-header dimensions, otherwise clients with different coding capabilities can receive an inappropriate stored response.
The minimum-length setting has a narrower contract than its name suggests. gzip_min_length determines length from the response’s Content-Length field. It does not necessarily know the final size of a response whose length is unspecified. A tiny dynamically streamed response can therefore be compressed even when an equally small static file falls below the configured threshold.
Measurements can also be misleading. The size of a decompressed output file is not the compressed transfer-body size, and compressed body bytes do not include all HTTP framing, headers, or TLS traffic. File extensions such as .gz prove nothing by themselves. Check the actual content coding and decode the bytes before interpreting any apparent bandwidth saving.
Check it quickly
Save this as check-gzip.py and run it with Python, Docker, and the pinned images. The recorded setup used nginx 1.28.0 on Linux arm64 and a small Python backend. nginx enables gzip at level three, allows JSON in addition to its built-in HTML eligibility, sets a 1,024-byte floor, and explicitly enables Vary:
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import http.client,json,socket,subprocess,time,uuid
NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609'
PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
if check:assert result.returncode==0,result.stderr
return result
def wait(predicate):
deadline=time.monotonic()+15
while time.monotonic()<deadline:
try:
if predicate():return
except OSError:pass
time.sleep(.1)
raise AssertionError('fixture did not become ready')
@contextmanager
def lab(configuration,files=None,backend=None):
name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend']
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration)
for path,content in (files or {}).items():
target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True)
target.write_bytes(content if isinstance(content,bytes) else content.encode())
docker('network','create',name)
if backend is not None:
(root/'backend.py').write_text(backend)
(root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n')
docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root))
docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1])
wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0)
(root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n')
docker('build','--network','none','-t',tags[0],str(root))
docker('run','--rm','--network',name,tags[0],'nginx','-t')
docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0])
details=json.loads(docker('inspect',containers[0]).stdout)[0]
binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort'])
def ready():
connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True
wait(ready)
def request(path,method='GET',headers=None,body=None):
connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5)
try:
connection.request(method,path,body=body,headers=headers or {})
response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read()
finally:connection.close()
request.port=port
yield containers[0],request
finally:
docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False)
def config(locations,extra=''):
return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n'
import gzip,hashlib
backend=r'''
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
protocol_version='HTTP/1.1'
def log_message(self,*args):pass
def do_GET(self):
self.send_response(200);self.send_header('Content-Type','application/json')
self.send_header('Connection','close');self.end_headers();self.wfile.write(b'{"ok":true}\n');self.close_connection=True
ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever()
'''
payload=json.dumps({'message':'compressible nginx response '*1000}).encode()
locations='''
location = /disabled.json { gzip off; gzip_types application/json; }
location = /novary.json { gzip_vary off; }
location = /stream { proxy_pass http://backend:8081; }
'''
extra='''
types { application/json json; text/html html; application/octet-stream bin; }
gzip on; gzip_types application/json; gzip_vary on; gzip_min_length 1024; gzip_comp_level 3;
'''
files={name:payload for name in ['large.json','other.bin','page.html','disabled.json','novary.json']};files['tiny.json']=b'{"ok":true}\n'
with lab(config(locations,extra),files,backend) as (_,request):
results={}
for encoding in ['gzip','identity','gzip;q=0','br']:
status,h,b=request('/large.json',headers={'Accept-Encoding':encoding});assert status==200
if encoding=='gzip':assert h['content-encoding']=='gzip' and gzip.decompress(b)==payload and len(b)<len(payload)
else:assert 'content-encoding' not in h and b==payload
assert h['vary']=='Accept-Encoding'
results[encoding]={'encoding':h.get('content-encoding','identity'),'body_bytes':len(b)}
for path,encoded in [('/tiny.json',False),('/other.bin',False),('/page.html',True),('/disabled.json',False),('/novary.json',True),('/stream',True)]:
status,h,b=request(path,headers={'Accept-Encoding':'gzip'});assert status==200
assert (h.get('content-encoding')=='gzip')==encoded,(path,h)
decoded=gzip.decompress(b) if encoded else b
assert decoded==(files['tiny.json'] if path=='/stream' else files[path[1:]])
if path=='/novary.json':assert 'vary' not in h
results[path]={'encoding':h.get('content-encoding','identity'),'body_bytes':len(b),'vary':h.get('vary')}
results['decoded_sha256']=hashlib.sha256(payload).hexdigest()
print(json.dumps(results,sort_keys=True))
The large synthetic JSON representation is 28,015 bytes. With Accept-Encoding: gzip, its transferred response body is 246 bytes in the recorded run, and Python’s gzip decoder reproduces the original bytes exactly. This intentionally repetitive payload demonstrates operation and integrity; its compression ratio is not a prediction for production JSON, JavaScript, or arbitrary text.
Requests accepting identity, setting gzip;q=0, or advertising only br receive the original bytes without a Content-Encoding field. Zero quality disallows gzip. Advertising Brotli does not enable a Brotli compressor in this nginx configuration, and identity remains acceptable in that request. The eligible large responses carry Vary: Accept-Encoding, including the uncompressed variants.
The same synthetic payload served as an ineligible binary type remains uncompressed, while the HTML type is eligible without appearing in gzip_types. A location explicitly setting gzip off also remains uncompressed despite listing JSON. These controls separate type eligibility from the enable switch, rather than treating every absent coding as evidence that negotiation failed.
The 12-byte static JSON response stays uncompressed because its known length is below the floor. The backend returns the same 12 bytes without Content-Length, ending the response by closing its connection. nginx compresses that response into 32 bytes. The floor therefore does not guarantee that all tiny responses avoid compression, and this particular tiny payload actually grows.
A final location keeps gzip active but turns gzip_vary off. Its body still arrives gzip encoded, with no Vary field. This makes the missing-cache-dimension failure observable without requiring a real shared cache to misdeliver content. The fixture checks the header policy; a deployed cache’s representation selection should also be tested through that cache.
Tune the policy using representative responses
Select content types whose actual bytes compress well. Already compressed media or archives usually offer little benefit from another coding layer, while repetitive text may shrink substantially. Do not infer CPU cost from this byte-count experiment. Compare representative sizes, compression settings, server utilization, and client latency before choosing a level for a busy service.
Keep the response metadata honest through proxies. Content-Encoding tells the recipient how to decode the representation, while Vary tells caches which request fields affect selection. If an application already sends an encoded response, review that path separately rather than adding a misleading header to unencoded bytes. The decisive check remains negotiated headers plus a successful byte-for-byte decode.
Sources
- nginx gzip module, enabling gzip, MIME eligibility, Content-Length thresholds, and Vary, checked 2026-09-12.
- RFC 9110 HTTP Semantics, Accept-Encoding, Content-Encoding, and representation selection, checked 2026-09-12.