How to Set Up Nginx with Django and Gunicorn

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

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:

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

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;
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;
}
location /admin/ {
    allow 203.0.113.0/24;
    deny all;
    proxy_pass http://unix:/var/www/myproject/gunicorn.sock;
}

Performance Tips

gzip on;
gzip_types text/html text/css application/javascript application/json;
gzip_min_length 512;

Real-World Use Cases

Best Practices Recap

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.

Exit mobile version