Optimising nginx and gunicorn for a small Django site

My website stack runs Django, gunicorn and nginx on a small server. I recently realised that I haven't really done any optimisations, other than the ones that came out of the box. For example, I was still making requests via HTTP/1.1, while HTTP/2 is the more established and much faster option.

This document outlines some of the simple speed-ups which can improve performance on similar smaller sites: 1. Enabling HTTP/2 2. Configuring gzip 3. Keeping gunicorn connections open 4. Other simple configuration wins

Note that more optimisations are available, but I decided not to do all of them (e.g. enabling Brotli compression instead of gzip or using HTTP/3 instead of HTTP/2), because the "additional complications" aren't worth the minor speed-ups of using those approaches.

Enabling HTTP/2

HTTP/2 sends multiple requests over one TCP connection, so requests no longer queue up behind each other on the browser side. It also compresses headers with HPACK - which is a useful speedup, since each request carries cookies, a long user-agent string, and a batch of Accept-* headers.

How to enable it?

nginx 1.22 already has the http_v2_module compiled in. Enabling it is a one-line change per vhost:

# before
listen 443 ssl;
# after
listen 443 ssl http2;

(nginx 1.25+ uses a standalone http2 on; directive instead, but the per-listen syntax still works.)

Confirm it works

curl -sI -o /dev/null -w "%{http_version}\n" https://your-site/

The expected response should be 2.

Configuring gzip properly

Most nginx installs ship with gzip on; and nothing else. The problem is that by default nginx only compresses text/html. JSON API responses, CSS, JS bundles, SVGs etc. still go out uncompressed.

How to enable it?

In nginx.conf inside the http {} block write:

gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types
    application/javascript
    application/json
    application/manifest+json
    application/rss+xml
    application/xml
    image/svg+xml
    text/css
    text/plain
    text/xml;
gzip_static on;

Some important explanations: - gzip_comp_level 5 - the compression level. You can set it to a higher level, but it will use more CPU. - gzip_min_length 256 - skips tiny responses where the gzip header overhead beats the savings. - gzip_static on makes nginx serve a pre-compressed .gz file if one exists. If you configure a build step that emits *.gz files, you can skip runtime compression entirely.

Installing the gzip_static module

gzip_static lives in ngx_http_gzip_static_module, which is not part of the stock nginx build. Check whether yours has it:

nginx -V 2>&1 | tr ' ' '\n' | grep gzip_static

If that prints --with-http_gzip_static_module, you already have it. If it prints nothing, nginx will refuse to start with unknown directive "gzip_static", and there are two ways out: - On Debian and Ubuntu, install a flavour that includes it: sudo apt install nginx-full (or nginx-extras). nginx-light leaves it out. - If you compiled nginx yourself, add --with-http_gzip_static_module to your ./configure line and rebuild.

Confirm it works

Request a static file and print only the response headers:

curl -s -o /dev/null -D - -H "Accept-Encoding: gzip" https://your-site/static/app.css

You should see content-encoding: gzip and vary: Accept-Encoding in the output.

Use a GET like the one above rather than curl -I - nginx skips the gzip filter on header-only responses, so a HEAD request never reports content-encoding, even when compression is working.

To see what the compression actually buys you, compare the transferred sizes:

curl -s -o /dev/null -w "plain: %{size_download}\n" https://your-site/static/app.css
curl -s -o /dev/null -w "gzip:  %{size_download}\n" -H "Accept-Encoding: gzip" https://your-site/static/app.css

Keeping gunicorn connections open

By default, every request nginx proxies to gunicorn opens a fresh TCP socket. On localhost that's cheap, but not free.

How to enable it?

Two changes are needed: - an upstream block on the nginx side and - a worker class on the gunicorn side that supports keepalive.

On nginx:

upstream django {
    server 127.0.0.1:8000;
    keepalive 32;
}

server {
    location / {
        include proxy_params;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_pass http://django;
    }
}

Both proxy_http_version 1.1; and proxy_set_header Connection ""; are required - nginx defaults to HTTP/1.0 for upstreams, and gunicorn won't hold a connection open without HTTP/1.1.

On gunicorn, the catch is that sync workers don't support keepalive at all. You need a worker class that does. gthread is the easiest swap:

gunicorn \
    --workers 3 \
    --worker-class gthread \
    --threads 4 \
    --keep-alive 5 \
    backend.wsgi:application

Confirm it works

Verify with ss after some traffic:

sudo ss -tan '( sport = :8000 or dport = :8000 )' | awk 'NR>1 {print $1}' | sort | uniq -c

You should see ESTABLISHED connections persisting between requests instead of nothing.

Other simple configuration wins

There are also some really simple configurations that make requests "run better": - Setting the following header tells browsers to "always use HTTPS for this domain for the next year":

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
- For SPA bundles where the build emits content-hashed filenames (index-Cji_oPzU.js), tell browsers to never revalidate them:
location /assets/ {
    root /var/www/my-web-app;
    add_header Cache-Control "public, max-age=31536000, immutable";
    try_files $uri =404;
}

This works because hashed content doesn't change (or at least isn't supposed to). If the browser already has this content-hashed file saved in its cache, it will serve the cached one instead of sending additional requests and checking for changes.