Enable Gzip and Brotli Compression for Django with Nginx
Compress Django responses and static files correctly: Nginx gzip settings, precompressed static assets, Brotli support, BREACH caveats, and checks.
Problem statement
Text responses (HTML, CSS, JavaScript, JSON, SVG) compress very well, often to a fraction of their original size. Serving them uncompressed makes pages slower on mobile networks and increases bandwidth costs. Many Django deployments either compress nothing, compress only some content types, or compress in several layers at once, wasting CPU.
Compression in a Django stack can happen in several places:
- Django's
GZipMiddlewarefor dynamic responses - WhiteNoise, which can serve precompressed static files
- Nginx, compressing responses on the fly or serving precompressed files
- a CDN in front of everything
A good setup compresses each response once, in the most efficient place, and does not compress data that is already compressed (images, videos, archives). This guide configures Nginx gzip for proxied Django responses, precompressed static files, optional Brotli, and explains the security caveat for compressing responses that contain secrets.
Quick answer
- Enable
gzipin Nginx with an explicitgzip_typeslist for text formats,gzip_proxied any,gzip_vary on, and a sensiblegzip_min_length. - Precompress static files at build time (WhiteNoise's compressed storage creates
.gzfiles, and.brfiles when the Brotli package is installed), and serve them withgzip_static onwhen Nginx serves static files. - Add Brotli with the
ngx_brotlimodule if your Nginx build or distribution package includes it, or let a CDN handle Brotli. - Do not also enable Django's
GZipMiddlewarewhen Nginx compresses proxied responses. - Leave images, fonts in compressed formats (WOFF2), video, and archives uncompressed.
- Verify with
curl -H "Accept-Encoding: gzip"andbr, checkingContent-Encodingand size.
Step-by-step solution
1. Measure the current state
curl -s -o /dev/null -w "%{size_download} bytes\n" https://www.example.com/
curl -s -o /dev/null -w "%{size_download} bytes\n" -H "Accept-Encoding: gzip" https://www.example.com/
curl -sI -H "Accept-Encoding: gzip, br" https://www.example.com/static/css/app.3f2a1b9c.css | grep -i -E "content-encoding|content-length|vary"
If both size measurements are equal and there is no Content-Encoding header, responses are not compressed.
2. Configure gzip in Nginx
Place these in the http block (for example /etc/nginx/conf.d/compression.conf):
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
application/rss+xml
application/atom+xml
image/svg+xml
application/manifest+json;
What each setting does:
gzip_types: Nginx compressestext/htmlby default; everything else must be listed. Without this list, CSS and JavaScript from proxied responses are sent uncompressed.gzip_proxied any: by default, Nginx does not compress responses to requests that came through another proxy (requests with aViaheader), which matters behind some load balancers and CDNs.gzip_vary on: addsVary: Accept-Encoding, so caches store compressed and uncompressed variants separately.gzip_comp_level 5: levels above about 5 cost noticeably more CPU for small additional savings on typical web content.gzip_min_length 1024: very small responses gain little and may even grow.
Test and reload:
sudo nginx -t && sudo systemctl reload nginx
3. Precompress static files
Compressing the same CSS file on every request wastes CPU. Precompress at build time instead.
With WhiteNoise's compressed manifest storage, collectstatic writes .gz versions, and .br versions when the Brotli Python package is installed:
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
pip install whitenoise brotli
python manage.py collectstatic --noinput
ls staticfiles/css/ | head
You should see files like app.3f2a1b9c.css, app.3f2a1b9c.css.gz, and app.3f2a1b9c.css.br.
- If WhiteNoise serves static files, it picks the best precompressed variant based on
Accept-Encodingautomatically. - If Nginx serves static files, enable
gzip_staticso Nginx serves existing.gzfiles instead of compressing on the fly:
location /static/ {
alias /srv/myapp/staticfiles/;
gzip_static on;
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip_static requires the ngx_http_gzip_static_module, which is included in the standard Nginx packages on Debian and Ubuntu. Check with nginx -V 2>&1 | grep -o with-http_gzip_static_module.
4. Add Brotli
Brotli typically produces smaller text payloads than gzip at comparable decompression speed, and all modern browsers support it over HTTPS. Nginx does not include Brotli in its core; it requires the ngx_brotli module.
Options:
- Distribution packages: some distributions ship the module as a separate package. Check your package manager (for example search for "brotli" in Nginx module packages) and load it with
load_moduledirectives. - Build the dynamic module against your exact Nginx version.
- Let the CDN do it: Cloudflare and other CDNs compress with Brotli between the edge and browsers regardless of origin encoding.
With the module loaded:
brotli on;
brotli_comp_level 5;
brotli_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
brotli_static on;
brotli_static on serves the .br files that WhiteNoise's storage created.
5. Avoid double compression
Choose one place to compress dynamic responses:
- Nginx in front of Gunicorn: let Nginx compress; do not add
django.middleware.gzip.GZipMiddleware. - No reverse proxy (for example a PaaS where Gunicorn is directly behind the platform router):
GZipMiddlewareor the platform's compression can be used.
Nginx does not recompress a response that already has a Content-Encoding header, so double compression wastes CPU in Django without breaking responses, but it removes Nginx's ability to choose a better algorithm.
6. Understand the BREACH caveat
BREACH is an attack against HTTPS responses that are compressed and contain both a secret (such as a CSRF token) and attacker-influenced input reflected in the same response. By observing compressed sizes over many requests, an attacker can infer the secret.
Mitigations in Django deployments:
- Django masks CSRF tokens with a per-response random value, so the token bytes differ in each response.
- Django's
GZipMiddlewareadds random bytes to the gzip header to make length-based attacks harder (a mitigation added in Django 4.2). - Avoid reflecting user input into pages that also contain long-lived secrets such as API keys.
For typical Django pages, compressing HTML is standard practice. For pages that display sensitive tokens, consider disabling compression for those specific locations.
7. Don't compress what is already compressed
Keep gzip_types limited to text formats. Adding image/jpeg, image/png, image/webp, font/woff2, or application/zip wastes CPU and can slightly increase sizes. SVG is text and compresses well, so it belongs in the list.
8. Verify
# HTML from Django through Nginx
curl -sI -H "Accept-Encoding: gzip" https://www.example.com/ | grep -i -E "content-encoding|vary"
# JSON API
curl -sI -H "Accept-Encoding: gzip" https://www.example.com/api/status/ | grep -i content-encoding
# Static CSS with Brotli preference
curl -sI -H "Accept-Encoding: br, gzip" https://www.example.com/static/css/app.3f2a1b9c.css | grep -i content-encoding
# Size comparison
for enc in identity gzip br; do
printf "%-8s" "$enc"
curl -s -o /dev/null -w "%{size_download}\n" -H "Accept-Encoding: $enc" https://www.example.com/static/css/app.3f2a1b9c.css
done
Verification check:
- HTML, CSS, JavaScript, and JSON responses include
Content-Encoding: gziporbr - responses include
Vary: Accept-Encoding - images and WOFF2 fonts are not compressed again
- CPU usage on Nginx does not jump noticeably after enabling compression
Explanation
Compression trades a little CPU for much smaller transfers. For static files, that CPU cost can be paid once at build time by precompressing, and the web server simply picks the right file. For dynamic responses, the reverse proxy is the natural place to compress because it already buffers responses and can apply the same policy to every upstream. Limiting compression to text types and choosing one layer for each kind of content keeps the benefit without wasted work.
Edge cases / notes
Streaming responses
Compression buffers data. For StreamingHttpResponse or server-sent events, compression can delay delivery of each chunk. Disable gzip for streaming locations with gzip off; and consider proxy_buffering off; for event streams.
CDNs
If a CDN sits in front, it may compress for browsers itself. Origin compression still reduces origin-to-CDN transfer, and with Vary: Accept-Encoding the CDN handles variants correctly.
HTTP/2 and HTTP/3
Compression of response bodies is independent of HTTP version. Header compression in HTTP/2 and HTTP/3 is automatic and separate from these settings.
Measuring the effect
Compression is worth verifying with numbers, both for bandwidth and page performance.
Per-asset savings with a simple script over your largest assets:
for path in /static/css/app.3f2a1b9c.css /static/js/app.8d7e6f5a.js /; do
plain=$(curl -s -o /dev/null -w "%{size_download}" -H "Accept-Encoding: identity" "https://www.example.com$path")
gz=$(curl -s -o /dev/null -w "%{size_download}" -H "Accept-Encoding: gzip" "https://www.example.com$path")
br=$(curl -s -o /dev/null -w "%{size_download}" -H "Accept-Encoding: br" "https://www.example.com$path")
echo "$path identity=$plain gzip=$gz br=$br"
done
curl reports downloaded (compressed) bytes because it does not decompress unless --compressed is passed.
Page-level impact: run Lighthouse or WebPageTest before and after, and compare total transfer size and largest contentful paint on a throttled mobile profile, where compression matters most.
Server cost: watch Nginx worker CPU during peak traffic after enabling compression. With precompressed static files and level 5 for dynamic responses, the increase is usually small; if it is not, lower gzip_comp_level for dynamic content.
Compression for API responses
JSON APIs benefit as much as HTML. Two details matter:
- Content type: Django REST Framework returns
application/json, which must be ingzip_types. Custom types such asapplication/vnd.api+jsonneed their own entry. - Small responses: many API responses are under
gzip_min_lengthand are left uncompressed, which is fine.
For clients that do not send Accept-Encoding (some server-to-server integrations), responses stay uncompressed automatically.
Common mistakes
- Enabling
gzip onwithoutgzip_types, leaving CSS and JavaScript uncompressed. - Compressing images and WOFF2 fonts.
- Using compression level 9 for dynamic responses on busy servers.
- Enabling both
GZipMiddlewareand Nginx compression without a reason.
Next steps
- Pair compression with long cache lifetimes in Long-Lived Caching for Django Static Files.
- Serve precompressed files from the app with How to Serve Django Static Files with WhiteNoise.
- Review the rest of the proxy configuration in How to Configure Nginx for Django in Production.
- Cache compressed responses with Cache Django Responses with Nginx proxy_cache.
FAQ
Should Django or Nginx compress responses?
When Nginx sits in front of Gunicorn, let Nginx compress dynamic responses and serve precompressed static files. Use Django's GZipMiddleware only when there is no reverse proxy that compresses.
Why are my CSS and JavaScript files not gzipped by Nginx?
gzip on only compresses text/html by default. Add CSS, JavaScript, JSON, and other text types to gzip_types, and set gzip_proxied any if requests come through another proxy.
Does Nginx support Brotli out of the box?
No. Brotli requires the ngx_brotli module, available as a separate package on some distributions or built as a dynamic module. A CDN can also provide Brotli compression.
Is gzip compression on HTTPS pages a security risk?
Compressed HTTPS responses that reflect attacker input alongside secrets can be vulnerable to BREACH. Django masks CSRF tokens and its gzip middleware adds mitigations; avoid putting long-lived secrets on pages that reflect user input.