Nginx + Gunicorn — The Canonical Python Deploy
The standard production stack for a Django/Flask/FastAPI app: nginx in front, Gunicorn (sync) or Uvicorn (async) workers behind, static files served by nginx directly, app served via Unix socket.
[ Internet ] ──HTTPS──▶ [ nginx ] ──HTTP via unix socket──▶ [ gunicorn workers ] ──▶ [ Django ]
│
└────────serves /static/ from disk──▶ [ /var/www/static/ ]
Why both?
| nginx | gunicorn | |
|---|---|---|
| TLS termination | yes | no (technically can but slow) |
| Static file serving | very fast (sendfile) |
slow (single-threaded WSGI worker handling each byte) |
| Slow client buffering | yes | no — slow clients tie up workers |
| HTTP/2, HTTP/3 | yes | no |
| Compression | yes | possible but slow |
| Concurrency model | event-driven (10k connections per worker) | sync workers (1 request per worker) |
| Python | no | yes — runs your app |
Gunicorn is great at running Python; nginx is great at everything HTTP-shaped. Pair them.
Gunicorn over a Unix socket
Faster than 127.0.0.1:8000 (no TCP stack, no port conflicts):
gunicorn myapp.wsgi:application \
--bind unix:/run/gunicorn.sock \
--workers 4 \
--worker-class sync \
--timeout 60 \
--access-logfile - \
--error-logfile -
For ASGI (FastAPI, Django async, Starlette):
gunicorn myapp.asgi:application \
--bind unix:/run/gunicorn.sock \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker
Or just Uvicorn directly:
uvicorn myapp.asgi:application --uds /run/gunicorn.sock --workers 4
nginx config
upstream django_app {
server unix:/run/gunicorn.sock; # Unix socket
keepalive 32;
}
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
client_max_body_size 50M; # default is 1M — uploads will 413 without this
# Static files served directly by nginx
location /static/ {
alias /var/www/myapp/static/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
location /media/ {
alias /var/www/myapp/media/;
expires 30d;
}
# Everything else proxied to Django
location / {
proxy_pass http://django_app;
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_set_header X-Forwarded-Host $host;
proxy_redirect off;
proxy_buffering on;
proxy_read_timeout 60s;
}
}
# HTTP → HTTPS redirect
server {
listen 80;
server_name api.example.com;
return 301 https://$host$request_uri;
}
Why static files via nginx, not Django
Django’s runserver and Gunicorn can serve static files via WhiteNoise, but:
- nginx’s
sendfile()is essentially free (~zero CPU for the bytes themselves). - A Gunicorn worker serving a 5 MB file is blocked for the duration; that’s 1 fewer worker for actual requests.
- nginx caches OS file handles and serves common files from page cache.
Use nginx for static. Reserve Gunicorn workers for Python work.
Django settings to make it work behind nginx
# settings.py
ALLOWED_HOSTS = ["api.example.com"]
# Trust X-Forwarded-Proto from the proxy
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
# Where collectstatic writes; nginx reads this directly
STATIC_ROOT = "/var/www/myapp/static/"
STATIC_URL = "/static/"
MEDIA_ROOT = "/var/www/myapp/media/"
MEDIA_URL = "/media/"
# If you're handling cookies and CSRF behind a proxy:
CSRF_TRUSTED_ORIGINS = ["https://api.example.com"]
Without SECURE_PROXY_SSL_HEADER, Django thinks every request is HTTP (because it actually is between nginx and Gunicorn). Then request.is_secure() returns False, redirects loop, secure cookies don’t get set.
For FastAPI/Starlette, mount the proxy headers middleware:
from starlette.middleware.proxy_headers import ProxyHeadersMiddleware
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
Worker count tuning
For sync workers (CPU-bound): (2 × cores) + 1 is the gunicorn default rule of thumb.
For async workers (I/O-bound, e.g. UvicornWorker): fewer workers, each handling many connections. workers = cores, sometimes less.
Memory budget per worker = total_RAM / worker_count minus OS overhead. A 2 GB server with 8 workers each loading 200 MB of code = OOM in production.
Profile before tuning. Most defaults are fine.
Graceful restarts on deploy
Gunicorn supports zero-downtime reloads via SIGHUP:
kill -HUP $(cat /run/gunicorn.pid)
Workers are replaced one at a time; nginx keeps proxying to the socket throughout. Combined with pidfile in your gunicorn invocation:
gunicorn ... --pid /run/gunicorn.pid
For systemd-managed Gunicorn:
systemctl reload gunicorn # if your unit file specifies ExecReload=/bin/kill -HUP $MAINPID
Common pitfalls
client_max_body_sizedefault is 1MB. Forgetting it = 413 on any upload bigger than 1 MB.- Missing
proxy_http_version 1.1+Connection ""= no upstream keepalive; high-RPS apps spend significant CPU on TCP handshakes. - Unix socket permissions. nginx runs as
www-data, gunicorn might run as another user; the socket needs to be readable by both. Use the same group, or--bind unix:/run/gunicorn.sock --umask 0. - Forgetting
SECURE_PROXY_SSL_HEADER— Django redirects loop on?next=because it thinks HTTPS is HTTP. - Long-running views without bumping
proxy_read_timeout— get 504 at 60s. - Static files in MEDIA_URL accidentally routed through Django — slow + wasteful. Always serve via nginx.
Alternative deployment shapes
| Stack | When |
|---|---|
| nginx + gunicorn | classic Django/Flask, sync code |
| nginx + uvicorn (gunicorn worker class) | async Django, FastAPI, Starlette |
| nginx + uWSGI | older Django; less common now |
| ALB + gunicorn (no nginx) | AWS-heavy, ALB does TLS + LB; static via S3+CloudFront |
| Caddy + gunicorn | smaller deploys, auto-TLS, simpler config |
| Direct Uvicorn | dev only — no slow-client buffering, no static file optimization |
| Containerized: nginx sidecar + app container | Kubernetes; both in the same pod or as separate services |
Logs to actually watch
/var/log/nginx/access.log— request log; tune format to include$request_timeand$upstream_response_time./var/log/nginx/error.log— 502/504/connection-refused diagnostics.- Gunicorn logs (stdout/stderr) — Python tracebacks, slow requests.
A useful access log format:
log_format with_timing '$remote_addr - "$request" $status $body_bytes_sent '
'rt=$request_time urt=$upstream_response_time '
'"$http_user_agent"';
access_log /var/log/nginx/access.log with_timing;
$request_time includes time talking to slow clients; $upstream_response_time is just Django. The difference flags slow-client patterns.
Common interview confusions
- “Why not just run Gunicorn on port 80?” — no TLS termination, no slow-client protection, no static file optimization, no HTTP/2, can’t reload without dropping connections cleanly.
- “Why a Unix socket instead of TCP?” — slightly faster (no TCP overhead), no port conflicts, OS-level access control via permissions. Both work; Unix is the convention for same-host setups.
- “WhiteNoise vs nginx for static files?” — WhiteNoise is fine for small apps and Heroku-style platforms where you can’t run nginx. For self-managed servers, nginx is the right answer.
Interview angle
- “Why both nginx and gunicorn?” — gunicorn runs Python; nginx does what nginx does well: TLS, static files, slow-client buffering, HTTP/2, compression. Each does its specialty.
- “How does the request flow from browser to Django?” — HTTPS to nginx → nginx terminates TLS, makes routing decision → forwards over Unix socket as plain HTTP → Gunicorn worker → Django view → response back the same path.
- “Why is
proxy_set_header Host $hostimportant?” — Django’sALLOWED_HOSTSchecks the Host header. Without forwarding, it sees the upstream’s name and rejects. - “What does
SECURE_PROXY_SSL_HEADERdo?” — tells Django to trustX-Forwarded-Protofrom nginx so it knows the original request was HTTPS even though nginx-to-Django is plain HTTP. - “Why serve static files via nginx, not Django?” —
sendfile()is near-free; a Gunicorn worker reading + sending a 5 MB file is one fewer worker for real requests. - “How many gunicorn workers?” — sync:
(2 × cores) + 1rule of thumb. Async (UvicornWorker): roughly equal to cores. Memory caps the upper bound.