DB-유

Enabling HTTP/2 and Gzip in nginx

· linux, nginx, http2, performance

Somebody complained our site felt slow, and the two cheapest fixes I know were sitting unused in nginx: HTTP/2 and gzip. HTTP/2 multiplexes requests over one connection and compresses headers, which removes most of the penalty for pages with dozens of assets. Gzip shrinks text responses (HTML, CSS, JavaScript, JSON) by 70 to 90 percent. Both come with the stock package and take a few lines, but the details around TLS, compression levels and proxied responses decide whether you actually benefit.

I'm on nginx 1.18 on Ubuntu 20.04, or the nginx.org package on CentOS 7 and 8. Anything 1.14 or later with http_v2_module should behave the same. Your site must already be on HTTPS, since browsers only speak HTTP/2 over TLS. Check your build first:

nginx -V 2>&1 | grep -o 'with-http_v2_module'
nginx -V 2>&1 | grep -o 'built with OpenSSL [0-9.]*'

You want OpenSSL 1.0.2 or later for ALPN, which is how the browser negotiates HTTP/2. Ubuntu 20.04 ships 1.1.1, which also gives you TLS 1.3.

HTTP/2 is one word

Add it to the listen directive in the site's server block:

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
    ...
}

Two notes. http2 applies to the whole listen socket, so every server block sharing *:443 gets it once any of them declares it; I put it on all of them for clarity. And HTTP/2 forbids some old cipher suites, so ssl_ciphers must hold only modern AEAD suites like the ones above, or Chrome fails the connection with ERR_SPDY_INADEQUATE_TRANSPORT_SECURITY.

The backend doesn't change. nginx speaks HTTP/2 to the browser and HTTP/1.1 (or FastCGI) to the upstream, as intended.

Gzip, globally

I put compression in /etc/nginx/nginx.conf inside http {} so every site gets it. The Ubuntu package ships most of these lines commented out:

    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 5;
    gzip_min_length 256;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_types
        text/plain
        text/css
        text/xml
        text/javascript
        application/javascript
        application/x-javascript
        application/json
        application/xml
        application/xml+rss
        application/rss+xml
        application/atom+xml
        image/svg+xml
        font/ttf
        font/opentype
        application/vnd.ms-fontobject;

Why these values:

  • gzip_types must be listed explicitly. text/html is always included and repeating it makes nginx warn. Don't add image/jpeg, image/png or font/woff2; they're already compressed and you'd burn CPU for nothing.
  • gzip_comp_level 5 is the sweet spot. Levels 6 to 9 cost noticeably more CPU for one or two percent smaller output.
  • gzip_min_length 256 skips tiny responses where the gzip header outweighs the savings.
  • gzip_proxied any compresses responses from proxy_pass upstreams. Without it, Node or PHP-FPM output mostly stays uncompressed, which surprised me.
  • gzip_vary on adds Vary: Accept-Encoding so caches keep compressed and plain copies apart.

Pre-compressing static files

Compressing the same app.js on every request is wasted work. With gzip_static, nginx serves an existing app.js.gz directly if the client accepts gzip:

    location ~* \.(css|js|svg)$ {
        gzip_static on;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

Generate the files at deploy time with find /var/www/example.com -type f \( -name '*.js' -o -name '*.css' -o -name '*.svg' \) -exec gzip -9 -k {} \;. The ngx_http_gzip_static_module is included in both the Ubuntu and nginx.org packages; check with nginx -V 2>&1 | grep gzip_static.

Reload and look

sudo nginx -t && sudo systemctl reload nginx

Is HTTP/2 negotiated?

curl -sI --http2 https://example.com/ | head -1

You want HTTP/2 200. If your workstation's curl is old and lacks --http2, use openssl s_client -alpn h2 -connect example.com:443 </dev/null 2>/dev/null | grep ALPN, which prints ALPN protocol: h2.

And compression, with a size comparison:

curl -sI -H 'Accept-Encoding: gzip' https://example.com/app.js | grep -iE 'content-encoding|vary'
curl -so /dev/null -w '%{size_download}\n' https://example.com/app.js
curl -so /dev/null -w '%{size_download}\n' -H 'Accept-Encoding: gzip' https://example.com/app.js

The first should show content-encoding: gzip; the third number should be a fraction of the second. In the browser's Network tab the Protocol column shows h2 per request, and Size shows transferred versus actual.

If it doesn't work

  • curl shows HTTP/1.1 despite the config: http2 is on a different server block sharing the port, or the build lacks http_v2_module. Also make sure no old spdy directive remains; it was removed in 1.9.5 and makes nginx -t fail.
  • ERR_SPDY_INADEQUATE_TRANSPORT_SECURITY in Chrome: a blacklisted cipher (CBC suites, anything without ECDHE) is still negotiable. Tighten ssl_ciphers.
  • Proxied JSON isn't compressed: no gzip_proxied any, or a type in gzip_types that doesn't match. nginx matches the MIME type before the semicolon, so application/json is enough. Also check the upstream isn't already setting Content-Encoding.
  • nginx: [warn] duplicate MIME type "text/html" in gzip_types: remove text/html from the list.
  • Compression works over HTTP/1.1 but not curl --http2: gzip_http_version 1.1 is fine for HTTP/2, but very old configs set gzip_http_version 1.0 with gzip_disable "msie6" in odd ways. Simplify to the block above.

Keep the cipher list current as browsers drop suites; that's also what keeps HTTP/2 working. Otherwise this is a set-and-forget change.