A complete production nginx config for Django behind gunicorn: correct proxy headers, keep-alive upstreams, immutable static caching, safe uploads, login and API rate limits, anonymous page caching and security headers that survive the add_header trap.
Most Django deployments put nginx in front of gunicorn with a copied twenty-line config and never touch it again. That config usually lies to Django about HTTPS, ties up workers on slow uploads, drops security headers on half the responses and lets every scanner on the internet reach Python. This is a complete, production-shaped nginx setup for Django, with the reasoning behind each directive so you can change it safely.
nginx terminates TLS and talks plain HTTP to gunicorn over a Unix socket. Django therefore sees every request as insecure unless nginx tells it otherwise and Django is configured to believe it. That trust must be one-way: nginx always overwrites the headers Django trusts, so a client can never inject them.
# myproject/settings/production.py
ALLOWED_HOSTS = ["app.example.com"]
# nginx sets X-Forwarded-Proto to $scheme on every proxied request.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Leave this False (the default). nginx forwards the real Host header,
# so Django validates it against ALLOWED_HOSTS directly.
USE_X_FORWARDED_HOST = False
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
STATIC_ROOT = "/srv/app/public/static"
MEDIA_ROOT = "/srv/app/public/media"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
},
}
DATA_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 # non-file body data
FILE_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 # larger files spool to disk
Without SECURE_PROXY_SSL_HEADER, request.is_secure() is false, build_absolute_uri() produces http:// links, and the CSRF origin check fails on HTTPS POSTs. Only turn on USE_X_FORWARDED_HOST when another proxy in front of nginx rewrites Host and you control what it sends. If you enable it while nginx passes a client-supplied X-Forwarded-Host through, you have opened a host-header injection hole into password-reset emails.
Run gunicorn with a worker type that supports keep-alive, and a timeout nginx can reason about:
gunicorn myproject.wsgi:application \
--bind unix:/run/gunicorn/myproject.sock \
--worker-class gthread --workers 4 --threads 4 \
--timeout 30 --graceful-timeout 30 --keep-alive 20
Everything that must live in the http context (shared memory zones, the cache path, map blocks) goes here. The site file then only references it.
# /etc/nginx/nginx.conf
user www-data;
worker_processes auto;
worker_rlimit_nofile 65535;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;
events {
worker_connections 4096;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
server_tokens off;
keepalive_timeout 30s;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log warn;
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types text/css text/plain text/xml application/javascript
application/json application/xml image/svg+xml;
# Rate limiting: count only POSTs to login; empty keys are not limited.
map $request_method $login_limit_key {
POST $binary_remote_addr;
default "";
}
limit_req_zone $login_limit_key zone=login:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=peraddr:10m;
limit_req_status 429;
limit_conn_status 429;
# Hashed static files (ManifestStaticFilesStorage) are immutable.
map $uri $static_cache_control {
"~\.[0-9a-f]{12}\.[A-Za-z0-9]+$" "public, max-age=31536000, immutable";
default "public, max-age=3600";
}
proxy_cache_path /var/cache/nginx/django levels=1:2
keys_zone=django_cache:20m max_size=1g
inactive=30m use_temp_path=off;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
worker_processes auto gives one worker per CPU core. The theoretical client ceiling is worker_processes × worker_connections. Every proxied request uses two connections, one to the client and one to gunicorn, so halve that figure. worker_rlimit_nofile has to be above worker_connections or you will hit "too many open files" before the connection limit. As a worked example, a 4-core box gets 4 workers × 4096 = 16,384 connections, or about 8,000 simultaneous proxied clients. Idle keep-alive browser connections, not active requests, are what normally use up that budget, which is why keepalive_timeout is 30s rather than the stock 65s. Because the master process runs as root, worker_rlimit_nofile can raise the workers' descriptor limit without a systemd override. Leave text/html out of gzip_types: it is always compressed, and listing it makes nginx warn about a duplicate MIME type.
Brotli is not in stock nginx. It needs the separately built ngx_brotli module loaded via load_module. Unless you have measured a meaningful win on your payloads, gzip plus pre-compressed static files (gzip_static on; with .gz files generated at build time) is enough. Do not also enable Django's GZipMiddleware. Compress in one place.
Keeping connections to gunicorn open saves a connect per request. Three things have to line up: the keepalive pool in the upstream, HTTP/1.1 towards the backend, and a cleared Connection header, because nginx otherwise sends Connection: close.
upstream django {
server unix:/run/gunicorn/myproject.sock;
keepalive 16;
keepalive_timeout 15s; # shorter than gunicorn --keep-alive 20
}
This only works with worker types that support persistent connections. gunicorn's sync worker ignores keep-alive entirely, so with sync workers the pool does nothing. Keep nginx's upstream keepalive_timeout below gunicorn's --keep-alive. If you get it the wrong way round, gunicorn closes an idle connection just as nginx reuses it, and you see occasional unexplained 502s.
Put the shared security headers in a snippet, because you will need to include it more than once (see the inheritance trap below):
# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# /etc/nginx/sites-available/myproject
upstream django {
server unix:/run/gunicorn/myproject.sock;
keepalive 16;
keepalive_timeout 15s;
}
# Unknown Host headers: drop them before they reach ALLOWED_HOSTS.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
ssl_reject_handshake on;
}
server {
listen 80;
listen [::]:80;
server_name app.example.com;
location /.well-known/acme-challenge/ { root /var/www/acme; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on; # nginx 1.25.1+; see the HTTP/2 section
server_name app.example.com;
ssl_certificate /etc/ssl/app.example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/app.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
include snippets/security-headers.conf;
client_max_body_size 10m;
limit_conn peraddr 30;
# Proxy defaults, inherited by every location below.
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 45s;
# nginx owns these headers; hide Django's copies to avoid duplicates.
proxy_hide_header X-Frame-Options;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header Referrer-Policy;
# Scanner noise: answered by nginx, never reaches Python.
location ~ /\. { access_log off; log_not_found off; return 404; }
location ~* (?:^/wp-|^/xmlrpc\.php|^/phpmyadmin|\.php$) {
access_log off;
return 444;
}
location ^~ /static/ {
root /srv/app/public;
access_log off;
include snippets/security-headers.conf;
add_header Cache-Control $static_cache_control;
}
location ^~ /media/ {
root /srv/app/public;
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=86400";
add_header Content-Security-Policy "default-src 'none'; sandbox" always;
}
location ~ ^/(?:accounts|admin)/login/$ {
limit_req zone=login burst=5 nodelay;
proxy_pass http://django;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://django;
}
location = /api/uploads/ {
limit_req zone=api burst=20 nodelay;
client_max_body_size 200m;
client_body_timeout 60s;
proxy_read_timeout 120s;
proxy_pass http://django;
}
location / {
proxy_pass http://django;
proxy_cache django_cache;
proxy_cache_key "$scheme$host$request_uri";
proxy_cache_valid 200 301 5m;
proxy_cache_valid 404 1m;
proxy_cache_bypass $cookie_sessionid $http_authorization;
proxy_no_cache $cookie_sessionid $http_authorization;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
include snippets/security-headers.conf;
add_header X-Cache-Status $upstream_cache_status always;
}
}
The X-Forwarded-For value appends to whatever the client sent, so the leftmost entry can be forged. If you need the client IP in Django, read X-Real-IP, which nginx sets from the TCP peer. If a load balancer sits in front of nginx, configure the realip module (set_real_ip_from, real_ip_header X-Forwarded-For, real_ip_recursive on) so that $remote_addr, and with it every rate-limit key, is the real client and not the balancer.
Location matching explains the layout. Regex locations beat plain prefix locations, so the dotfile and PHP rules catch /api/.env too. ^~ on /static/ and /media/ stops regex evaluation, so file requests skip the scanner regexes entirely.
The syntax changed in nginx 1.25.1, and Ubuntu 24.04 ships 1.24. Check nginx -v before copying anything.
| nginx version | Syntax | Scope |
|---|---|---|
| 1.24 and earlier (Ubuntu 24.04 package) | listen 443 ssl http2; | Whole address:port, every server on it |
| 1.25.1 and later | listen 443 ssl; + http2 on; | Per server block |
http2 on; is an unknown directive on 1.24, so nginx -t fails. On 1.25.1+ the old listen ... http2 still works but logs a deprecation warning. Because the old flag belongs to the listening socket, on 1.24 you cannot run HTTP/2 for one virtual host and HTTP/1.1 for another on the same IP and port. The new directive lifts that restriction. HTTP/2 only applies between browser and nginx. The hop to gunicorn stays HTTP/1.1, which is fine: it is a local socket with keep-alive, and multiplexing buys nothing there.
Serving files straight from disk with sendfile is orders of magnitude cheaper than routing them through a gunicorn worker. Use root rather than alias: with root the URI is appended to the path, and there is no trailing-slash mismatch to create a path traversal (location /static { alias /srv/app/public/static/; } is the classic bug).
The $static_cache_control map sends immutable with a one-year max-age only for files whose names carry the 12-hex-digit hash from ManifestStaticFilesStorage, such as css/base.5af66c1b1797.css. Unhashed files, which third-party packages sometimes reference directly, get one hour. Blanket one-year caching of /static/ without hashing means users keep stale JavaScript after a deploy.
Media is user-supplied content. The sandboxing CSP and nosniff stop an uploaded HTML or SVG file from running script on your origin. Private files do not belong under /media/ at all. Serve them through a Django view that checks permissions and returns an X-Accel-Redirect header pointing at an internal location. Django handles the authorisation, and nginx streams the bytes with sendfile:
# documents/views.py
import mimetypes
import os
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.http import content_disposition_header
from .models import Document
@login_required
def download(request, pk):
doc = get_object_or_404(Document, pk=pk, owner=request.user)
content_type, _ = mimetypes.guess_type(doc.file.name)
response = HttpResponse(content_type=content_type or "application/octet-stream")
response["Content-Disposition"] = content_disposition_header(
as_attachment=True, filename=doc.original_filename
)
# doc.file.name is a generated ASCII path, e.g. "2025/06/3f2a...e1.pdf"
response["X-Accel-Redirect"] = f"/protected/{doc.file.name}"
return response
# inside the HTTPS server block
location ^~ /protected/ {
internal;
alias /srv/app/private/;
}
internal makes the location return 404 to any direct client request. It is only reachable through the header Django sets. Both sides of the alias end in a slash, which is the pairing that avoids traversal. Store private files under generated ASCII names such as UUIDs and keep the human-readable name in a model field. That avoids any question about how non-ASCII paths are encoded in the redirect header. Keep /srv/app/private outside MEDIA_ROOT, or the files are also served publicly by the /media/ location.
| Setting | What it measures | Value here |
|---|---|---|
proxy_connect_timeout | Establishing the upstream connection | 5s |
proxy_read_timeout | Gap between two reads from gunicorn, not total time | 45s |
proxy_send_timeout | Gap between two writes to gunicorn | 60s |
gunicorn --timeout | Worker silent this long is killed and restarted | 30s |
client_body_timeout | Gap between two reads of the request body | 60s default |
Keep proxy_read_timeout above gunicorn's --timeout. A stuck request then gets its worker killed and recycled, and the client sees a 502. With the values the other way round, nginx returns 504 while the worker keeps grinding on a request nobody is waiting for. Raising timeouts globally to hide a slow report view is the wrong fix. Move that work to a task queue.
client_max_body_size is 10m server-wide and 200m only on the upload endpoint. Anything bigger gets a 413 from nginx before Django sees a byte. Leave proxy_request_buffering at its default on: nginx then reads the whole body, spooling to disk past client_body_buffer_size, before contacting gunicorn. A user uploading 200 MB over a phone connection ties up an nginx connection, which is cheap, instead of a gunicorn thread, which is not. Set proxy_request_buffering off; only for an async backend that genuinely streams bodies. Note that nginx cannot retry an unbuffered request on another upstream.
When the limit is hit, the client gets nginx's bare HTML 413 page, which an API client cannot parse. Return JSON instead by adding the error_page line to the upload location and the named location to the server block:
error_page 413 = @too_large;
location @too_large {
default_type application/json;
return 413 '{"detail": "File exceeds the 200 MB upload limit."}';
}
Django enforces its own limits behind nginx. Non-file form data larger than DATA_UPLOAD_MAX_MEMORY_SIZE raises RequestDataTooBig and returns a 400. DATA_UPLOAD_MAX_NUMBER_FIELDS and DATA_UPLOAD_MAX_NUMBER_FILES cap how many fields and files a request can contain. Treat nginx as the outer, larger wall and Django as the precise inner one.
limit_req is a leaky bucket. rate=10r/m allows one request every six seconds. burst=5 lets five extra requests queue. nodelay serves those queued requests immediately instead of spacing them out, and anything past the burst gets 429 straight away. Without nodelay, excess requests are held open and delayed, which suits smoothing API clients but only wastes connections on a login form.
The login zone is keyed through a map that returns an empty string for anything but POST. nginx does not account requests with an empty key, so rendering the login page is never limited, only credential attempts. A 10 MB zone holds state for well over a hundred thousand addresses.
limit_conn peraddr 30 caps concurrent connections per IP. Under HTTP/2, each concurrent stream counts separately, so keep the value generous enough for a page loading many assets. Treat nginx limits as coarse flood protection. Per-account lockout and credential-stuffing detection still belong in Django, because attackers rotate IPs and a whole office can share one.
One inheritance rule catches people out. limit_req, add_header, proxy_set_header and proxy_hide_header are inherited from the enclosing level only if the current level defines none of its own. Adding a single proxy_set_header inside a location silently drops all the server-level ones for that location.
For content-heavy sites (marketing pages, docs, catalogues), caching anonymous HTML in nginx for a few minutes absorbs traffic spikes that would otherwise queue behind your gunicorn workers. Requests with a sessionid cookie or an Authorization header skip the cache (proxy_cache_bypass) and are never stored (proxy_no_cache). You need both directives. If you rename SESSION_COOKIE_NAME, the variable becomes $cookie_<name>.
By default nginx does not store responses that carry Set-Cookie, Cache-Control: private/no-cache/no-store or Vary: *, and it honours max-age. That is your safety net. Admin and login views send never_cache headers, and a page that renders {% csrf_token %} sends Set-Cookie. Do not add Set-Cookie to proxy_ignore_headers. proxy_cache_valid only applies when Django sends no caching headers, so drive per-view TTLs from Django with cache_control(max_age=...).
The common hit-rate killer is Vary: Cookie, which Django adds whenever a view touches the session or request.user. nginx stores a separate variant per distinct Cookie header, so analytics cookies fragment the cache. proxy_ignore_headers Vary; fixes it, but only if the bypass rules above really catch every personalised response.
proxy_cache_use_stale ... updating with proxy_cache_background_update on serves the old copy while one request refreshes it, and proxy_cache_lock on collapses concurrent misses into a single upstream request. Together they are real protection against a thundering herd. X-Cache-Status reports HIT, MISS, BYPASS, EXPIRED, STALE or UPDATING. Do not use this for sites where most traffic is logged in: the hit rate will be close to zero, and the complexity buys nothing.
sudo install -d -o www-data -g www-data -m 700 /var/cache/nginx/django
Without always, add_header only applies to 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 responses. Your 404s, 429s and 502 error pages would then go out without HSTS or nosniff. Use always for security headers.
The trap: any add_header in a location discards every add_header inherited from the server block. This config looks fine and silently strips HSTS and X-Frame-Options from every page:
server {
add_header X-Frame-Options "DENY" always;
location / {
add_header X-Cache-Status $upstream_cache_status always; # others gone here
proxy_pass http://django;
}
}
That is why the snippet is included again in every location that adds its own header. nginx -T | grep -n add_header is the fastest audit.
Decide who owns each header. Django's SecurityMiddleware and XFrameOptionsMiddleware already emit X-Content-Type-Options, Referrer-Policy and X-Frame-Options. The config above hides Django's copies so that nginx is the single source and static files, media and error pages carry the same headers. The trade-off is that per-view decorators such as xframe_options_exempt stop working. If you rely on them, let Django own that header instead. HSTS lives in nginx here, so manage.py check --deploy will warn that SECURE_HSTS_SECONDS is unset. Silence that one check deliberately. Keep Content-Security-Policy in Django, where it can vary per view and use nonces.
Take a realistic case. myproject runs on one 4-core server with the gunicorn command above: 4 workers × 4 threads = 16 requests in flight. An average page takes about 120 ms of Django time, so the app ceiling is roughly 130 requests per second before requests start queuing in the socket backlog. On launch morning a newsletter sends 60 requests per second to the landing and pricing pages. At the same time, a botnet starts posting stolen credentials to /accounts/login/ from a few hundred addresses.
The default combined log format cannot tell you whether a slow request was Django, a slow client or a cache miss. Add timings and the cache and limiter verdicts in the http context before you need them:
log_format timed '$remote_addr [$time_local] "$request" $status $body_bytes_sent '
'rt=$request_time urt=$upstream_response_time '
'uct=$upstream_connect_time cache=$upstream_cache_status '
'lr=$limit_req_status';
access_log /var/log/nginx/access.log timed;
$request_time covers the whole exchange, including sending the response to the client. $upstream_response_time covers only gunicorn. A request with rt=4.9 and urt=0.110 is a slow mobile client, which nginx absorbed thanks to response buffering. A request where both are high is your code. A high uct means gunicorn's accept queue is full, and that is the first sign you are out of workers.
# Slowest upstream paths in the last log
awk '{for (i=1;i<=NF;i++) if ($i ~ /^urt=/) print substr($i,5), $5}' \
/var/log/nginx/access.log | sort -rn | head -20
# Cache effectiveness
grep -oE 'cache=[A-Z-]+' /var/log/nginx/access.log | sort | uniq -c
# Who is being rate limited
awk '$7 == 429 {print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
Landing and pricing pages are anonymous, so with a 5-minute proxy_cache_valid nearly all of the 60 requests per second come out as HIT. proxy_cache_lock ensures that when an entry expires, one request goes to Django while the rest wait briefly or, with updating in proxy_cache_use_stale, get the stale copy. Django sees a few requests per minute for those URLs instead of 60 per second, and the 16 worker threads stay free for logged-in users, who bypass the cache. If Django does fall over, the stale entries keep marketing pages online with X-Cache-Status: STALE while you fix it.
The credential stuffing is where nginx limits run out. Each of a few hundred addresses sends one POST every 20 seconds, well under 10 per minute per IP, so limit_req never fires. The zone stops a single noisy host, not a distributed attack. The defence that works is in Django: count failed logins per username and per network range in Redis, require a CAPTCHA or add delay after a threshold, and alert on the global failure rate. nginx still helps by capping how fast any one address can go, which forces the attacker to spread out and makes the pattern visible in the lr= and status columns.
Picking rate and burst by guesswork causes self-inflicted outages, typically when an office or a mobile carrier's NAT puts hundreds of real users behind one address. Start in dry-run mode, which evaluates the limit and logs what it would have done without rejecting anything:
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_dry_run on;
proxy_pass http://django;
}
Run it for a few days of normal traffic, then count lr=REJECTED_DRY_RUN entries per address. If the only addresses that would have been rejected are ones you recognise as abusive, remove limit_req_dry_run. If your own mobile app's heaviest legitimate users show up, raise the burst before enforcing. Where a known partner needs higher limits, flag their address range with a geo block and feed that into a map that produces an empty key for flagged requests. The zone does not count empty keys, so you exempt them without raising the limit for everyone.
add_header in a location and losing every server-level security header there. The same inheritance applies to proxy_set_header and limit_req.SECURE_PROXY_SSL_HEADER while nginx does not overwrite X-Forwarded-Proto, so any client can claim HTTPS. The opposite mistake, a missing header, causes redirect loops with SECURE_SSL_REDIRECT and CSRF failures.keepalive_timeout longer than gunicorn's --keep-alive, which gives intermittent 502s.$binary_remote_addr behind a load balancer without the realip module. Every visitor then shares one bucket and legitimate users get 429s.Set-Cookie to proxy_ignore_headers, which serves one user's page to another.proxy_read_timeout shorter than gunicorn's timeout, which leaves orphaned workers running after nginx has already returned 504.sudo nginx -t && sudo systemctl reload nginx
sudo nginx -T | grep -nE 'add_header|limit_req|http2'
# Headers, HTTP/2 and cache status: run twice, expect MISS then HIT
curl -sI --http2 https://app.example.com/ | grep -iE '^HTTP|x-cache-status|strict-transport|x-frame'
curl -sI -H 'Cookie: sessionid=test' https://app.example.com/ | grep -i x-cache-status # BYPASS
# Security headers survive on errors
curl -sI https://app.example.com/does-not-exist/ | grep -i strict-transport
# Hashed static file is immutable
curl -sI https://app.example.com/static/css/base.5af66c1b1797.css | grep -i cache-control
# Login rate limit: expect a few 403s (no CSRF token), then 429s
for i in $(seq 1 12); do
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://app.example.com/accounts/login/
done
# Upload ceiling: expect 413
head -c 300M /dev/urandom > /tmp/big.bin
curl -s -o /dev/null -w '%{http_code}\n' -F file=@/tmp/big.bin https://app.example.com/api/uploads/
# Scanner paths never reach gunicorn: expect 404, then an empty reply
curl -s -o /dev/null -w '%{http_code}\n' https://app.example.com/.env
curl -s https://app.example.com/wp-login.php; echo "exit=$?"
python manage.py check --deploy
Afterwards, watch /var/log/nginx/error.log for "limiting requests" and "upstream prematurely closed" lines during the first day of real traffic. Once every check here passes, nginx is handling TLS, static files, abuse and cacheable traffic by itself, and gunicorn only receives requests that actually need Python.