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:
bindto 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) + 1as a starting point for sync workers. Adjust based on actual load testing.max_requestswithmax_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 = Truein 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_HOSTSexplicitly, never leave it as["*"]in production — Django will reject requests with an unrecognizedHostheader, 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
geventworkers instead (pip install gevent, thenworker_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
collectstaticsupports hashed filenames viaManifestStaticFilesStorage, which lets you set very longexpiresheaders safely since any content change produces a new filename. - Use
select_related/prefetch_relatedand 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_cachefor 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 withgeventworkers 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_HEADERcorrectly propagated HTTPS status through to DRF’s own security checks.
Best Practices Recap
- Always run
collectstaticand let Nginx serve/static/and/media/directly — never route these through Gunicorn. - Set
SECURE_PROXY_SSL_HEADERin 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 = Trueor a wildcardALLOWED_HOSTS. - Enable
SECURE_SSL_REDIRECT,SESSION_COOKIE_SECURE, andCSRF_COOKIE_SECUREonly 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.