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 GZipMiddleware for 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.

Compressing a responseBrowserAccept-EncodingNginxgzip or brotliStatic filespre-compressedResponsesmaller payloadPre-compress static assets at build time and compress dynamic responses on the fly.

Quick answer

  1. Enable gzip in Nginx with an explicit gzip_types list for text formats, gzip_proxied any, gzip_vary on, and a sensible gzip_min_length.
  2. Precompress static files at build time (WhiteNoise's compressed storage creates .gz files, and .br files when the Brotli package is installed), and serve them with gzip_static on when Nginx serves static files.
  3. Add Brotli with the ngx_brotli module if your Nginx build or distribution package includes it, or let a CDN handle Brotli.
  4. Do not also enable Django's GZipMiddleware when Nginx compresses proxied responses.
  5. Leave images, fonts in compressed formats (WOFF2), video, and archives uncompressed.
  6. Verify with curl -H "Accept-Encoding: gzip" and br, checking Content-Encoding and 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;
gzip in Nginx, line by linegzip on;gzip_varyon;gzip_proxiedany;gzip_comp_level5;gzip_min_length 1024;gzip_typestext/css application/javascript application/json …;12341Adds Vary: Accept-Encoding so caches keep both versions.2Also compress responses proxied from Gunicorn.3Most of the gain for little CPU; 9 is rarely worth it.4text/html is always included; list the rest.Images and fonts like WOFF2 are already compressed; leave them outdjango-deployment.com
gzip_min_length skips tiny responses where headers outweigh the savings.

What each setting does:

  • gzip_types: Nginx compresses text/html by 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 a Via header), which matters behind some load balancers and CDNs.
  • gzip_vary on: adds Vary: 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.

compress once, serve many timesOn the fly✕CPU spent on every requestmoderate level to stay fast✓needed for dynamic HTMLPrecompressed .gz and .br✓built once at deploy✓maximum compression level✓gzip_static on; brotli_static onWhiteNoise can generate the compressed files during collectstaticdjango-deployment.com
Precompress static files; compress dynamic responses on the fly.

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-Encoding automatically.
  • If Nginx serves static files, enable gzip_static so Nginx serves existing .gz files 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.

the same bundle, three encodingsUncompressed480 KBgzip level 9~140 KBBrotli level 11~120 KB0 KB125 KB250 KB375 KB500 KBKBIllustrative sizes; measure your own bundle with curldjango-deployment.com
Brotli usually saves another 10 to 20 percent over gzip on text.

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_module directives.
  • 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): GZipMiddleware or 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.

is this response BREACH-sensitive?Does it contain a secret andreflect attacker-controlled input?noCompressstatic files, most pagesyes, CSRF tokenCompressDjango masks it per responseyes, other secretsMask or disablefor that responseBREACH needs many requests and a reflected guess, so rate limits help toodjango-deployment.com
Static assets never carry per-user secrets, so they are always safe to compress.

Mitigations in Django deployments:

  • Django masks CSRF tokens with a per-response random value, so the token bytes differ in each response.
  • Django's GZipMiddleware adds 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: gzip or br
  • 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 in gzip_types. Custom types such as application/vnd.api+json need their own entry.
  • Small responses: many API responses are under gzip_min_length and 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 on without gzip_types, leaving CSS and JavaScript uncompressed.
  • Compressing images and WOFF2 fonts.
  • Using compression level 9 for dynamic responses on busy servers.
  • Enabling both GZipMiddleware and Nginx compression without a reason.

Next steps

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.

Learning path · step 10 of 12

Making Django fast

Keep going