What the Docs Don’t Explain
Nginx’s gzip compression and charset modules work well in isolation. Combined, they expose a series of edge cases that can corrupt responses, break browser compatibility, or silently disable compression. This post documents the most significant nginx gzip encoding issues encountered in production, with the config and source-level explanation for each.
The Output Filter Chain: Why Module Order Matters
Nginx processes the response through an output filter chain. Filters are modules registered in a specific order. The gzip filter (ngx_http_gzip_filter_module) and the charset filter (ngx_http_charset_module) both sit in this chain. The critical detail: in nginx’s default build, the charset filter runs before the gzip filter. This means charset conversion happens on the uncompressed body, then gzip compresses the result. If you re-order the filters (possible by recompiling), charset tries to convert already-compressed bytes — producing garbage.
The charset + gzip Double-Header Problem
When both gzip_types and charset_types include text/html, nginx may set both Content-Encoding: gzip and a charset parameter. This is correct behavior, but some proxy servers and load balancers strip the charset from Content-Type when they see Content-Encoding: gzip, assuming the body is binary. The result is that downstream clients receive gzip-compressed content without a charset declaration, falling back to the browser’s default encoding.
Fix: explicitly declare charset in your application’s Content-Type header (charset=utf-8 in the header itself) rather than relying on nginx’s charset module. nginx will not override an already-set charset.
# Safer approach: set charset explicitly charset off; # disable nginx charset module # Rely on application to emit: Content-Type: text/html; charset=utf-8
gzip_vary and the Vary: Accept-Encoding Header
gzip_vary on instructs nginx to add Vary: Accept-Encoding to gzip-compressed responses. This is essential for correct caching — without it, a proxy cache may serve a gzip-compressed response to a client that sent no Accept-Encoding: gzip. However, some CDNs and reverse proxies strip the Vary header or do not normalize it, causing the same problem.
A subtler issue: if your upstream application also sets Vary: Accept-Encoding and nginx adds it again, you get Vary: Accept-Encoding, Accept-Encoding — a duplicated header. Most clients handle this fine, but some strict HTTP parsers do not. Use proxy_hide_header Vary before proxy_pass to control what comes from upstream.
The IE6 Compatibility Bug
Nginx’s gzip module has a built-in check: gzip_disable “msie6” (enabled by default). This disables gzip for Internet Explorer 6, which had a broken gzip implementation for responses with Content-Type other than text/html. Even in 2024/2025, this directive sometimes causes confusion: if your User-Agent string contains the substring ‘MSIE’ followed by a version number that the regex matches, nginx will not gzip the response.
If you have custom user agents in testing or monitoring that happen to match this pattern, gzip will be silently disabled. Check with curl –compressed and compare response sizes, or add the ‘X-Debug-Gzip’ response header via add_header after your gzip config to trace.
proxy_pass and Double Compression
When nginx acts as a reverse proxy and the upstream server sends gzip-compressed responses, nginx by default passes the compressed body through without decompression. If you also have gzip on in the nginx config, nginx does not double-compress — it detects the Content-Encoding: gzip from upstream and skips the gzip filter.
However, if you need to apply the sub_filter or charset modules to the upstream response, nginx must decompress first. Use gunzip on (requires ngx_http_gunzip_module) to decompress the upstream response, apply your filters, and let nginx re-compress. Without gunzip on, sub_filter silently fails on compressed upstreams — one of the most common and confusing nginx gzip encoding issues in proxy configurations.
# Decompress upstream, apply filter, recompress gunzip on; sub_filter ‘old_domain’ ‘new_domain’; sub_filter_once off; gzip on;
gzip_min_length and Small Responses
gzip_min_length 256 (or similar) is a best practice — compressing very small responses wastes CPU and can actually increase response size due to gzip header overhead. But the interaction with charset is subtle: if the charset module converts a 200-byte ASCII response to UTF-16, the response size doubles before gzip sees it. If the pre-conversion size is below gzip_min_length but the post-conversion size exceeds it, gzip will compress. If you measure response sizes at the application layer (pre-charset conversion), your gzip_min_length reasoning may be off by a factor of 2.
Debugging Checklist
When debugging nginx gzip encoding issues: use curl -H ‘Accept-Encoding: gzip’ -v to see response headers. Check Content-Encoding and Content-Type in the actual response. Use zcat or gzip -d to decompress and inspect the body. Add access_log with a custom format including $gzip_ratio to see per-request compression ratios. Check error.log at debug level with debug_connection for the client IP to trace filter execution.
Conclusion
Nginx gzip and charset modules are individually well-documented but their interactions are not. The output filter chain order, the Vary header semantics, the gunzip module for proxy scenarios, and the gzip_min_length measurement point are all sources of production surprises. Understanding the output filter chain (Blog 04) is the foundation for predicting these interactions reliably.


Leave a Reply