Enabling HTTP/2 and Gzip in nginx
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_typesmust be listed explicitly.text/htmlis always included and repeating it makes nginx warn. Don't addimage/jpeg,image/pngorfont/woff2; they're already compressed and you'd burn CPU for nothing.gzip_comp_level 5is the sweet spot. Levels 6 to 9 cost noticeably more CPU for one or two percent smaller output.gzip_min_length 256skips tiny responses where the gzip header outweighs the savings.gzip_proxied anycompresses responses fromproxy_passupstreams. Without it, Node or PHP-FPM output mostly stays uncompressed, which surprised me.gzip_vary onaddsVary: Accept-Encodingso 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
curlshowsHTTP/1.1despite the config:http2is on a different server block sharing the port, or the build lackshttp_v2_module. Also make sure no oldspdydirective remains; it was removed in 1.9.5 and makesnginx -tfail.ERR_SPDY_INADEQUATE_TRANSPORT_SECURITYin Chrome: a blacklisted cipher (CBC suites, anything without ECDHE) is still negotiable. Tightenssl_ciphers.- Proxied JSON isn't compressed: no
gzip_proxied any, or a type ingzip_typesthat doesn't match. nginx matches the MIME type before the semicolon, soapplication/jsonis enough. Also check the upstream isn't already settingContent-Encoding. nginx: [warn] duplicate MIME type "text/html" in gzip_types: removetext/htmlfrom the list.- Compression works over HTTP/1.1 but not
curl --http2:gzip_http_version 1.1is fine for HTTP/2, but very old configs setgzip_http_version 1.0withgzip_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.