Nginx Configuration Patterns for Django
Production Nginx config reference for Django — reverse proxy setup, static file serving, WebSocket proxying, rate limiting, gzip, security headers, and multi-site patterns.
Nginx sits in front of every Django deployment I’ve run. This is the configuration reference I reach for — covering the common patterns from basic proxy setup to WebSocket, rate limiting, and security headers.
Minimal Django Reverse Proxy
upstream django_app {
server unix:/run/gunicorn.sock fail_timeout=0;
# Or TCP: server 127.0.0.1:8000;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://django_app;
proxy_set_header Host $http_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;
}
}
Why Unix socket over TCP port: The socket avoids the TCP stack entirely for local connections — lower latency and no port conflicts. Use TCP only if Nginx and Gunicorn are on different hosts.
Static Files
server {
# ...
location /static/ {
alias /home/deploy/myproject/staticfiles/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
location /media/ {
alias /home/deploy/myproject/mediafiles/;
expires 7d;
add_header Cache-Control "public";
}
location = /favicon.ico {
alias /home/deploy/myproject/staticfiles/favicon.ico;
access_log off;
log_not_found off;
}
}
Nginx serves static files directly from disk — Django never sees these requests. immutable tells browsers the file won’t change as long as the URL is the same (safe because collectstatic hashes filenames).
HTTP → HTTPS Redirect
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# ... rest of config
}
WebSocket Proxying (Django Channels)
server {
# ...
# WebSocket connections must be explicitly proxied with Upgrade headers
location /ws/ {
proxy_pass http://django_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400; # 24 hours — don't time out long-lived connections
proxy_send_timeout 86400;
}
# Regular HTTP traffic
location / {
proxy_pass http://django_app;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
}
}
The Upgrade and Connection headers are what transform an HTTP request into a WebSocket handshake. Without them, WebSocket connections fail silently from the client’s perspective.
Rate Limiting
# In http{} block (nginx.conf or conf.d/default.conf)
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
server {
# ...
# Rate-limit the API
location /api/ {
limit_req zone=api burst=10 nodelay;
limit_req_status 429;
proxy_pass http://django_app;
# ...
}
# Strict limit on auth endpoints
location /api/auth/login/ {
limit_req zone=login burst=3 nodelay;
limit_req_status 429;
proxy_pass http://django_app;
# ...
}
}
burst=10 nodelay allows a burst of 10 requests to pass immediately, then enforces the rate. Without nodelay, bursts are queued and delayed rather than rejected. For login endpoints, you usually want strict rejection (nodelay).
Gzip Compression
# In http{} block
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
gzip_types
application/json
application/javascript
application/xml
text/css
text/html
text/javascript
text/plain
text/xml;
Don’t gzip images, videos, or already-compressed formats — it wastes CPU for no gain. gzip_min_length 256 skips tiny responses where the header overhead exceeds savings.
Security Headers
server {
# ...
# Prevent clickjacking
add_header X-Frame-Options "SAMEORIGIN" always;
# Prevent MIME type sniffing
add_header X-Content-Type-Options "nosniff" always;
# XSS protection (legacy browsers)
add_header X-XSS-Protection "1; mode=block" always;
# Force HTTPS for 1 year, include subdomains
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Referrer policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Hide Nginx version from error pages
server_tokens off;
}
always is important — without it, headers are only added on 2xx responses, not on errors like 404/500.
Client Upload Size
server {
# ...
# Default is 1MB — increase for file upload endpoints
client_max_body_size 20M;
# For specific upload paths only
location /api/upload/ {
client_max_body_size 100M;
proxy_pass http://django_app;
proxy_read_timeout 300; # uploading takes longer
}
}
Proxy Timeouts
server {
# ...
location / {
proxy_pass http://django_app;
proxy_connect_timeout 10s; # time to establish connection to Gunicorn
proxy_send_timeout 30s; # time to send request to Gunicorn
proxy_read_timeout 120s; # time to receive response from Gunicorn
# ...
}
}
proxy_read_timeout is the one that matters most. If Django has a slow endpoint (report generation, bulk processing), increase this — otherwise Nginx closes the connection with a 504 Gateway Timeout.
Buffer Tuning
server {
location / {
proxy_pass http://django_app;
proxy_buffering on;
proxy_buffer_size 8k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
}
}
With buffering on, Nginx reads the full response from Gunicorn into memory before sending to the client. This frees Gunicorn workers faster — a worker handling a slow client would block with buffering off. For streaming responses (Server-Sent Events, large file downloads), set proxy_buffering off on those specific locations.
Multi-App on One Server
# App 1: main site
server {
listen 443 ssl http2;
server_name example.com;
location / {
proxy_pass http://unix:/run/gunicorn_main.sock;
# ...
}
}
# App 2: API subdomain
server {
listen 443 ssl http2;
server_name api.example.com;
location / {
proxy_pass http://unix:/run/gunicorn_api.sock;
# ...
}
}
# App 3: Admin behind basic auth
server {
listen 443 ssl http2;
server_name admin.example.com;
location / {
auth_basic "Admin Area";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://unix:/run/gunicorn_main.sock;
# ...
}
}
Each app gets its own Unix socket. Run htpasswd -c /etc/nginx/.htpasswd adminuser to create the password file.
Logging
http {
log_format main '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'$request_time'; # adds response time in seconds
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
}
server {
# Per-server log
access_log /var/log/nginx/mysite_access.log main;
error_log /var/log/nginx/mysite_error.log;
# Disable logging for specific paths
location /health/ {
access_log off;
return 200 "ok";
}
}
Health Check Endpoint
server {
# ...
# Nginx-level health check (doesn't hit Django)
location = /nginx-health {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# Django-level health check
location = /health/ {
proxy_pass http://django_app;
access_log off;
}
}
Load balancers ping the health endpoint. Responding at Nginx level avoids warming up Django for health checks. Use the Django-level endpoint when you want to verify DB connectivity too.
Useful Commands
# Test config for syntax errors
sudo nginx -t
# Reload config without dropping connections
sudo systemctl reload nginx
# Full restart (drops active connections)
sudo systemctl restart nginx
# View access log live
sudo tail -f /var/log/nginx/access.log
# View error log
sudo tail -f /var/log/nginx/error.log
# Check which config file is loaded
sudo nginx -T | head -20