HTTP/2 changes HTTP transport framing and multiplexing; gzip and Brotli encode representation data. Enabling HTTP/2 does not automatically compress response bodies, and enabling gzip does not prove that a client negotiated HTTP/2. HTTP/2 header compression is another mechanism again. A reliable check records the negotiated protocol, response Content-Encoding, transferred body size, and the bytes obtained after decoding.

When it breaks

Requesting Accept-Encoding: gzip, br and saving the result as .br does not prove Brotli was used. The server may choose gzip or send an acceptable identity representation. The response header names the actual coding. Guessing from the request list or output filename can lead to applying the wrong decoder and reporting compression results that never occurred.

Brotli support is also a deployment capability, not an implication of nginx being installed. Separate modules provide Brotli filtering or precompressed-file handling. Their availability and build compatibility must be established for the actual binary and configuration. Adding an unsupported directive can prevent configuration validation, while merely advertising Brotli from the client cannot install server functionality.

Automatic client decoding creates another measurement trap. curl --compressed can save a large decoded file after receiving a much smaller encoded body. Comparing that file’s size with a raw compressed output compares different things. Conversely, using --http2 without checking the negotiated version can mistake an allowed protocol fallback for a successful HTTP/2 test.

Check it quickly

Save the script as check-h2-coding.py and run it with Docker, Python, OpenSSL, and a curl build supporting HTTP/2. The recorded client was curl 8.7.1 with nghttp2; the server was nginx 1.28.0 on Linux arm64. It generates a short-lived local certificate and explicitly trusts that certificate with --cacert, while publishing only a loopback port:

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
payload=(b'{"message":"'+b'HTTP content coding and transport are separate. '*1000+b'"}\n')
with TemporaryDirectory(prefix='xbcatm-local-tls-') as directory:
    root=Path(directory);cert=root/'cert.pem';key=root/'key.pem'
    result=subprocess.run(['openssl','req','-x509','-newkey','ec','-pkeyopt','ec_paramgen_curve:prime256v1',
        '-nodes','-keyout',str(key),'-out',str(cert),'-days','1','-subj','/CN=localhost',
        '-addext','subjectAltName=DNS:localhost,IP:127.0.0.1'],capture_output=True,text=True)
    assert result.returncode==0,result.stderr
    configuration=config('location = /app.json {} location / { return 404; }',
        'gzip on; gzip_types application/json; gzip_vary on; types { application/json json; }')
    configuration=configuration.replace('listen 8080;', 'listen 8080 ssl; http2 on; ssl_certificate /srv/cert.pem; ssl_certificate_key /srv/key.pem;')
    files={'cert.pem':cert.read_bytes(),'key.pem':key.read_bytes(),'app.json':payload,
        'brotli.conf':'events {} http { brotli on; server { listen 8080; } }'}
    with lab(configuration,files) as (proxy,request):
        probe=docker('exec',proxy,'nginx','-t','-c','/srv/brotli.conf',check=False)
        assert probe.returncode!=0 and 'unknown directive "brotli"' in probe.stderr
        results={'brotli_directive_supported':False}
        for name,version,accept,decode in [
            ('h2_gzip','2','gzip, br',False),('h1_gzip','1.1','gzip',False),
            ('h2_br_only','2','br',False),('h2_identity','2','identity',False),
            ('h2_decoded','2','gzip',True)]:
            output=root/(name+'.body');header=root/(name+'.headers')
            args=['curl','--silent','--show-error','--fail','--noproxy','*','--cacert',str(cert),
                '--http2' if version=='2' else '--http1.1','--header','Accept-Encoding: '+accept,
                '--output',str(output),'--dump-header',str(header),'--write-out','%{http_version}\n%{size_download}\n',
                f'https://127.0.0.1:{request.port}/app.json']
            if decode:args.insert(1,'--compressed')
            run=subprocess.run(args,capture_output=True,text=True,timeout=15);assert run.returncode==0,run.stderr
            actual,download=run.stdout.strip().splitlines();assert actual==version,(name,run.stdout)
            headers={k.lower():v.strip() for line in header.read_text().splitlines() if ':' in line for k,v in [line.split(':',1)]}
            body=output.read_bytes();encoded='gzip' in accept
            assert headers.get('content-encoding')==('gzip' if encoded else None)
            assert (gzip.decompress(body) if encoded and not decode else body)==payload
            assert headers['vary']=='Accept-Encoding'
            results[name]={'protocol':actual,'content_encoding':headers.get('content-encoding','identity'),
                'download_body_bytes':int(download),'saved_file_bytes':len(body)}
        assert results['h2_gzip']['download_body_bytes']<len(payload)
        assert results['h2_decoded']['saved_file_bytes']==len(payload)
        rejected={}
        for path in ['/key.pem','/cert.pem','/brotli.conf']:
            run=subprocess.run(['curl','--silent','--show-error','--noproxy','*','--cacert',str(cert),
                '--http2','--output',str(root/'rejected.body'),'--write-out','%{http_code}',
                f'https://127.0.0.1:{request.port}'+path],capture_output=True,text=True,timeout=15)
            assert run.returncode==0 and run.stdout=='404',(path,run.stdout,run.stderr)
            rejected[path]=404
        results['non_asset_statuses']=rejected
        results['original_sha256']=hashlib.sha256(payload).hexdigest()
        print(json.dumps(results,sort_keys=True))

The certificate includes the loopback IP in its subject alternative names, so the local HTTPS request can validate both trust and the requested identity. The fixture does not disable certificate verification. Only the JSON asset is routed for serving; requests for the key, certificate, and probe configuration must return 404. Temporary key material stays within the disposable test files and image, and the experiment removes its containers, image tags, network, and temporary files afterward.

The synthetic JSON representation contains 48,015 bytes. Over negotiated HTTP/2, a request advertising gzip and Brotli receives Content-Encoding: gzip and a 381-byte body in the recorded run. Decoding reproduces every original byte. Explicit HTTP/1.1 produces gzip too. These outcomes show that the body coding works across both tested transports rather than depending on HTTP/2 being selected.

An HTTP/2 identity request receives 48,015 unencoded bytes. A request advertising only br also receives identity in this configuration, which remains acceptable under that header. The fixture separately tests a configuration containing brotli on and requires nginx to reject it as an unknown directive. Together, those observations establish that this tested setup did not serve Brotli.

With --compressed, curl still reports a 381-byte downloaded body, but the saved file contains the original 48,015 decoded bytes. The script checks both measurements and the content itself. The downloaded-body figure excludes other transport overhead; it is not a complete packet capture or a measurement of all TLS and HTTP/2 framing bytes on the connection.

Verify capabilities before comparing performance

The example enables HTTP/2 explicitly on a TLS listener and checks curl’s resulting protocol value. It serves static content locally, so it makes no claim about an upstream application’s transport. A reverse proxy can negotiate one protocol with clients and use another with its backend; inspect each connection separately when that distinction affects capacity or behavior.

For Brotli deployment, establish the module or precompressed serving path, then require an actual br response and a successful Brotli decode before measuring its savings. This experiment provides a negative capability check, not a Brotli benchmark. Installing a module or rebuilding nginx is a separate configuration change with its own compatibility and serving checks.

Finally, compare representative content with consistent measurements. This highly repetitive test body demonstrates correct coding and decoding, not a universal compression ratio or page-speed improvement. Record cache selection through Vary, encoded body bytes, decoding correctness, CPU use, and client latency as distinct observations. Neither a transport version nor a codec name alone establishes that a page became faster.

Sources