backend / protocols / nginx / 07_gunicorn_python_deploy.md

Nginx + Gunicorn — The Canonical Python Deploy

6 interview angles 6 min read source

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

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 $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.