Backend / Protocols / nginx / 07_gunicorn_python_deploy.md

Nginx + Gunicorn — The Canonical Python Deploy

Updated 6 interview angles 6 min read source
On this page12
  1. Why both?
  2. Gunicorn over a Unix socket
  3. nginx config
  4. Why static files via nginx, not Django
  5. Django settings to make it work behind nginx
  6. Worker count tuning
  7. Graceful restarts on deploy
  8. Common pitfalls
  9. Alternative deployment shapes
  10. Logs to actually watch
  11. Common interview confusions
  12. Interview angle

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.

text
[ 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):

bash
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):

bash
gunicorn myapp.asgi:application \
    --bind unix:/run/gunicorn.sock \
    --workers 4 \
    --worker-class uvicorn.workers.UvicornWorker

Or just Uvicorn directly:

bash
uvicorn myapp.asgi:application --uds /run/gunicorn.sock --workers 4

nginx config

nginx
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

python
# 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:

python
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:

bash
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:

bash
gunicorn ... --pid /run/gunicorn.pid

For systemd-managed Gunicorn:

bash
systemctl reload gunicorn      # if your unit file specifies ExecReload=/bin/kill -HUP $MAINPID

Common pitfalls

  • client_max_body_size default 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_time and $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:

nginx
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 6

  • “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 $host important?” — Django’s ALLOWED_HOSTS checks the Host header. Without forwarding, it sees the upstream’s name and rejects.
  • “What does SECURE_PROXY_SSL_HEADER do?” — tells Django to trust X-Forwarded-Proto from 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) + 1 rule of thumb. Async (UvicornWorker): roughly equal to cores. Memory caps the upper bound.