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.