How to Set Up Nginx with Django and Gunicorn

How to Set Up Nginx with Django and Gunicorn

Django’s manage.py runserver is explicitly documented as unfit for production, and unlike some frameworks where that warning is a formality, Django means it — the dev server is single-threaded, has no real process management, and will fall over under any meaningful concurrent load. The standard, well-worn production pattern is Gunicorn (Green Unicorn) as the WSGI application server running your Django app, with Nginx in front handling static files, TLS, and everything else at the network edge. I’ve deployed this combination more times than any other Python stack, and it’s about as reliable a pattern as exists in the Python web world.

This guide covers the full setup: Gunicorn configuration, systemd process management, Nginx reverse proxying, static/media file handling (which Django developers specifically tend to get wrong), and the production checklist that goes with it.

Requirements

  • Ubuntu 22.04/24.04 server with sudo access
  • Python 3.10+ and a Django project ready to deploy
  • PostgreSQL (or your database of choice) already set up and reachable — this guide focuses on the web-serving layer, not the database
  • A domain pointed at your server

I’ll assume your project lives at /var/www/myproject, with the Django project package named myproject and the WSGI application at myproject/wsgi.py (Django creates this automatically via django-admin startproject).

Step 1: Install System Dependencies

sudo apt update
sudo apt install python3-pip python3-venv nginx libpq-dev -y

libpq-dev is needed if you’re using PostgreSQL via psycopg2; skip it if you’re on a different database.

Step 2: Set Up the Virtual Environment

cd /var/www/myproject
python3 -m venv venv
source venv/bin/activate
pip install django gunicorn psycopg2-binary

Or, more realistically:

pip install -r requirements.txt
pip install gunicorn

Step 3: Configure Django for Production

Before touching Gunicorn or Nginx, make sure settings.py is production-ready:

# settings.py
import os

DEBUG = False

ALLOWED_HOSTS = ["example.com", "www.example.com"]

STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")

MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, "media")

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

That last line, SECURE_PROXY_SSL_HEADER, is essential and frequently missed: it tells Django to trust Nginx’s X-Forwarded-Proto header when deciding whether the original request was HTTPS. Without it, Django thinks every request is plain HTTP (since the actual Nginx↔Gunicorn hop is unencrypted), which breaks request.is_secure(), CSRF handling in some configurations, and any SECURE_SSL_REDIRECT logic.

Run migrations and collect static files:

python manage.py migrate
python manage.py collectstatic --noinput

collectstatic gathers every app’s static files into STATIC_ROOT — this is the directory Nginx will serve directly, bypassing Django entirely for static content.

Step 4: Test Gunicorn Directly

Before wiring up systemd or Nginx, confirm Gunicorn can serve the app on its own:

gunicorn --bind 0.0.0.0:8000 myproject.wsgi:application

Visit http://your-server-ip:8000. If your Django app responds (even if static files look broken right now — that’s expected, Gunicorn doesn’t serve static files), the WSGI wiring is correct. Stop it with Ctrl+C.

Step 5: Create a Gunicorn Configuration File

nano /var/www/myproject/gunicorn_config.py
bind = "unix:/var/www/myproject/gunicorn.sock"
workers = 3
worker_class = "sync"
timeout = 30
max_requests = 1000
max_requests_jitter = 50
accesslog = "/var/www/myproject/logs/gunicorn_access.log"
errorlog = "/var/www/myproject/logs/gunicorn_error.log"
loglevel = "info"

A quick rundown:

  • bind to a Unix socket rather than a TCP port — same reasoning as the Ruby/Flask guides in this series: it’s faster for same-machine communication and doesn’t expose Gunicorn on any network-reachable port.
  • workers = 3 — Gunicorn’s own documentation recommends (2 x CPU cores) + 1 as a starting point for sync workers. Adjust based on actual load testing.
  • max_requests with max_requests_jitter — restarts each worker after roughly 1000 requests (jittered to avoid all workers restarting simultaneously), which helps guard against gradual memory growth in long-running Python processes.
  • timeout = 30 — kills and restarts a worker that’s been silent (no response to the master process) for 30 seconds, protecting against hung requests.

Create the logs directory:

mkdir -p /var/www/myproject/logs

Step 6: Run Gunicorn as a systemd Service

sudo nano /etc/systemd/system/gunicorn_myproject.service
[Unit]
Description=Gunicorn daemon for myproject
After=network.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/myproject
ExecStart=/var/www/myproject/venv/bin/gunicorn \
          --config /var/www/myproject/gunicorn_config.py \
          myproject.wsgi:application

Restart=always

[Install]
WantedBy=multi-user.target

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable gunicorn_myproject
sudo systemctl start gunicorn_myproject
sudo systemctl status gunicorn_myproject

Confirm the socket was created:

ls -la /var/www/myproject/gunicorn.sock

If the service fails to start, check:

sudo journalctl -u gunicorn_myproject -n 50

Step 7: Configure Nginx

sudo nano /etc/nginx/sites-available/myproject
server {
    listen 80;
    server_name example.com www.example.com;

    client_max_body_size 20M;

    location /static/ {
        alias /var/www/myproject/staticfiles/;
        expires 30d;
        add_header Cache-Control "public";
    }

    location /media/ {
        alias /var/www/myproject/media/;
        expires 7d;
    }

    location / {
        proxy_pass http://unix:/var/www/myproject/gunicorn.sock;
        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_connect_timeout 30s;
        proxy_read_timeout 30s;
    }
}

Notice the proxy_pass http://unix:... syntax — this is how Nginx proxies HTTP requests to a Unix socket rather than a TCP address; it’s a slightly different syntax from the uwsgi_pass used with uWSGI, since Gunicorn speaks plain HTTP over the socket rather than a dedicated binary protocol.

The two alias blocks for /static/ and /media/ are what actually make Django deployments work correctly in production — this is the step I see skipped most often by people new to Django deployment, resulting in a functional site with completely unstyled pages because Gunicorn was never meant to serve static files efficiently and doesn’t attempt to by default outside of DEBUG mode.

Enable the site:

sudo ln -s /etc/nginx/sites-available/myproject /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

Step 8: Fix File Permissions

Since Gunicorn runs as www-data per the systemd unit, and Nginx’s worker processes also default to www-data, permissions on staticfiles/ and media/ usually just work. Explicitly confirm:

sudo chown -R www-data:www-data /var/www/myproject/staticfiles
sudo chown -R www-data:www-data /var/www/myproject/media

If user-uploaded media needs to be writable by Django at runtime (via FileField/ImageField), double check media/ has appropriate write permissions for the www-data user, not just read.

Step 9: Add HTTPS

sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d example.com -d www.example.com

Once HTTPS is confirmed working, also enable Django’s own security settings:

# settings.py
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

Only set SECURE_SSL_REDIRECT = True after HTTPS is fully working end-to-end — enabling it prematurely, before Nginx is correctly forwarding the protocol header, causes an infinite redirect loop (Django keeps redirecting to HTTPS because it thinks every incoming request, even ones already over HTTPS via Nginx, is plain HTTP).

Testing Your Setup

curl -I https://example.com

Test a page that renders static assets (CSS/JS) to confirm the /static/ alias is working:

curl -I https://example.com/static/admin/css/base.css

This should return 200, confirming Django’s own admin CSS (a reliable built-in test file that exists in every Django project) is being served directly by Nginx.

Test the Django admin login page specifically — it’s a good end-to-end smoke test since it touches the database, session framework, CSRF protection, and static files all at once:

curl -I https://example.com/admin/

Troubleshooting Common Issues

502 Bad Gateway — Check Gunicorn is running and the socket exists with correct permissions:

sudo systemctl status gunicorn_myproject
ls -la /var/www/myproject/gunicorn.sock

Site loads but completely unstyled (no CSS) — collectstatic wasn’t run, or the alias path in Nginx doesn’t match STATIC_ROOT in settings.py. Double check both, and confirm python manage.py collectstatic actually populated the expected directory.

“CSRF verification failed” errors specifically in production — Almost always missing CSRF_TRUSTED_ORIGINS in Django 4.0+, which now requires explicit trusted origins for cross-origin POST requests:

CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://www.example.com"]

Infinite redirect loop after enabling SECURE_SSL_REDIRECT — SECURE_PROXY_SSL_HEADER isn’t set correctly, or Nginx isn’t sending X-Forwarded-Proto. Revisit Step 3 and the Nginx proxy_set_header X-Forwarded-Proto $scheme; line.

Uploaded media files return 404 — Check the /media/ alias path matches MEDIA_ROOT, and confirm the file was actually saved to disk (check with ls directly) rather than assuming it’s purely an Nginx config issue.

Gunicorn workers keep timing out on slow requests — A long-running report generation or export endpoint might genuinely need more than 30 seconds; either increase timeout in gunicorn_config.py for the whole app, or better, move genuinely slow operations to a background task queue (Celery is the standard Django pairing) rather than stretching HTTP timeouts to accommodate them.

Security Considerations

  • Never leave DEBUG = True in production. This is Django-specific but critical — debug mode exposes full stack traces, settings values, and SQL queries to anyone who triggers an error, which is a serious information disclosure risk.
  • Set ALLOWED_HOSTS explicitly, never leave it as ["*"] in production — Django will reject requests with an unrecognized Host header, which is an important defense against certain HTTP request smuggling and cache poisoning techniques.
  • Bind Gunicorn to a Unix socket only, never a public TCP port.
  • Add standard security headers in Nginx:
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
  • Rate limit the admin and login endpoints:
limit_req_zone $binary_remote_addr zone=djangologin:10m rate=5r/m;

location /admin/login/ {
    limit_req zone=djangologin burst=5 nodelay;
    proxy_pass http://unix:/var/www/myproject/gunicorn.sock;
}
  • Consider restricting /admin/ by IP if it’s only ever accessed by your team:
location /admin/ {
    allow 203.0.113.0/24;
    deny all;
    proxy_pass http://unix:/var/www/myproject/gunicorn.sock;
}

Performance Tips

  • Tune Gunicorn’s worker count and class based on your workload. Sync workers (the default) are fine for CPU-bound or fast I/O apps; if your Django views do a lot of waiting on external APIs, consider gevent workers instead (pip install gevent, then worker_class = "gevent") for much higher concurrency per worker.
  • Enable gzip in Nginx for HTML/JSON/CSS/JS responses:
gzip on;
gzip_types text/html text/css application/javascript application/json;
gzip_min_length 512;
  • Cache aggressively for static files — Django’s collectstatic supports hashed filenames via ManifestStaticFilesStorage, which lets you set very long expires headers safely since any content change produces a new filename.
  • Use select_related/prefetch_related and database indexing — this is outside Nginx’s scope entirely, but the single biggest performance lever in most Django apps is the ORM usage pattern, not the web server layer. No amount of Nginx tuning fixes an N+1 query problem.
  • Consider proxy_cache for read-heavy, non-personalized views (see the caching proxy guide in this series) if specific pages are expensive to render and don’t need per-request freshness.
  • Move slow operations to Celery rather than letting them occupy a Gunicorn worker for the duration of an HTTP request — this keeps your web-facing workers responsive for everyone else.

Real-World Use Cases

  • A Django-based SaaS admin dashboard, where Nginx handled static asset caching aggressively (long expires, hashed filenames) while Gunicorn ran with gevent workers to comfortably handle many concurrent users each making frequent small AJAX requests.
  • A public content site built on Django, where /media/ served large volumes of user-uploaded images directly through Nginx, completely bypassing Python for what would otherwise have been a significant and unnecessary Gunicorn workload.
  • An internal Django REST Framework API consumed by a separate frontend SPA, where Nginx enforced CORS headers and rate limiting per API token before requests ever reached Gunicorn, and SECURE_PROXY_SSL_HEADER correctly propagated HTTPS status through to DRF’s own security checks.

Best Practices Recap

  • Always run collectstatic and let Nginx serve /static/ and /media/ directly — never route these through Gunicorn.
  • Set SECURE_PROXY_SSL_HEADER in Django so it correctly recognizes HTTPS requests forwarded from Nginx.
  • Bind Gunicorn to a Unix socket, managed via systemd for automatic restarts.
  • Never deploy with DEBUG = True or a wildcard ALLOWED_HOSTS.
  • Enable SECURE_SSL_REDIRECT, SESSION_COOKIE_SECURE, and CSRF_COOKIE_SECURE only after confirming HTTPS and header forwarding both work correctly end to end.
  • Tune Gunicorn’s worker count and class to your actual workload, not a generic default.
  • Push genuinely slow work to a background task queue rather than stretching HTTP timeouts.

This Nginx-plus-Gunicorn pattern has been the backbone of Django deployments for years for good reason — it’s well documented, well understood, and every piece of it does exactly one job well. Once it’s running, deploys are routine: pull new code, run migrations, collectstatic, restart the Gunicorn service, and Nginx keeps the site up the entire time.

Total
1
Shares

Leave a Reply

Previous Post
How to Use Variables in Nginx Configuration

How to Use Variables in Nginx Configuration

Next Post
How to Set Up Nginx with Apache as a Reverse Proxy

How to Set Up Nginx with Apache as a Reverse Proxy

Related Posts