Deploying With Gunicorn, Nginx, and systemd
Introduction
python manage.py runserver is for local development only—it is single-threaded, not hardened, and must never face the public internet. Production Django runs behind Gunicorn (WSGI workers), Nginx (reverse proxy, TLS, static files), and systemd (process supervision). This chapter deploys hello-django on Linux using environment-based settings from Settings and Multi-Environment.
Prerequisites
- Settings and Multi-Environment —
production.py - Static and Media in Production —
collectstatic - Security Best Practices —
DEBUG=False - Linux VPS — Linux track
Development vs Production
runserver | Gunicorn + Nginx | |
|---|---|---|
| Purpose | Local dev | Production |
| Auto reload | Yes | Redeploy to update |
| Static files | Dev helper | Nginx or WhiteNoise |
| Security | Debug pages | DEBUG=False |
Warning
Never Expose runserver Publicly
Use Gunicorn (or Uvicorn for ASGI) bound to localhost behind Nginx.
Install Gunicorn
pip install gunicornAdd to requirements.txt.
Run Gunicorn Manually
From project root (folder with manage.py):
export DJANGO_SETTINGS_MODULE=config.settings.production
export DJANGO_SECRET_KEY="your-production-secret"
export DJANGO_ALLOWED_HOSTS="example.com,www.example.com"
python manage.py migrate
python manage.py collectstatic --noinput
gunicorn config.wsgi:application -w 4 -b 127.0.0.1:8000Code explanation:
config.wsgi:application— WSGI callable from project structure-w 4— worker processes (start with2 × CPU + 1)-b 127.0.0.1:8000— local only; Nginx is public-facing
Unix socket (optional):
gunicorn config.wsgi:application -w 4 --bind unix:/run/hello-django.sockNginx proxy_pass http://unix:/run/hello-django.sock.
Worker Tuning
| Option | Meaning |
|---|---|
-w 4 | Worker count |
--timeout 120 | Kill hung workers (seconds) |
--access-logfile - | Access log to stdout (Docker/systemd) |
--error-logfile - | Error log to stdout |
--max-requests 1000 | Recycle workers (memory leak mitigation) |
Align --timeout with Nginx proxy_read_timeout for slow uploads.
Health Check Route
blog/views.py:
from django.http import JsonResponse
def health(request):
return JsonResponse({"status": "ok"})blog/urls.py:
path("health/", views.health, name="health"),Load balancers probe GET /health/—keep it lightweight (optional DB ping for deep checks).
Environment File (12-Factor)
/etc/hello-django/env (root-only, chmod 600):
DJANGO_SETTINGS_MODULE=config.settings.production
DJANGO_SECRET_KEY=long-random-string
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
DB_NAME=hello_django
DB_USER=django_user
DB_PASSWORD=strong-password
DB_HOST=127.0.0.1
DB_PORT=3306Never commit this file—mirror structure in .env.example without secrets for developers.
systemd Unit
/etc/systemd/system/hello-django.service:
Enable:
sudo systemctl daemon-reload
sudo systemctl enable --now hello-django
sudo systemctl status hello-django
journalctl -u hello-django -fDeploy updates:
cd /var/www/hello-django
git pull
source .venv/bin/activate
pip install -r requirements.txt
python manage.py migrate
python manage.py collectstatic --noinput
sudo systemctl restart hello-djangoNginx Configuration
/etc/nginx/sites-available/hello-django:
Enable and test:
sudo ln -s /etc/nginx/sites-available/hello-django /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxSee Nginx reverse proxy, Tomcat + Nginx pattern (same proxy_pass idea), and proxying Spring Boot.
HTTPS: Let's Encrypt.
ASGI Alternative (Awareness)
For async views or Django Channels:
pip install uvicorn
uvicorn config.asgi:application --host 127.0.0.1 --port 8000This track’s sync apps use WSGI + Gunicorn by default.
File Permissions
sudo chown -R www-data:www-data /var/www/hello-django
sudo chmod 750 /var/www/hello-django
sudo mkdir -p /var/www/hello-django/media /var/www/hello-django/logsGunicorn runs as www-data; Nginx reads static; Django writes uploads to media/.
Pre-Flight Checklist
DJANGO_SETTINGS_MODULE=config.settings.production python manage.py check --deploy
curl -I http://127.0.0.1:8000/health/
curl -I http://example.com/static/blog/css/style.cssCommon Problems
| Symptom | Fix |
|---|---|
| 502 Bad Gateway | Gunicorn down; check systemctl status, socket/port |
| DisallowedHost | Add domain to ALLOWED_HOSTS |
| Static 404 | Run collectstatic; verify Nginx alias path |
| Media 404 | Create media/; Nginx location /media/ |
| 502 after long request | Increase Gunicorn/Nginx timeouts |
| Permission denied on socket | www-data ownership on /run/ socket |
Post-Chapter Checklist
- Gunicorn serves
config.wsgi:applicationon localhost - systemd unit starts on boot and restarts on failure
- Nginx proxies
/and serves/static/,/media/ -
migrateandcollectstaticin deploy script -
check --deploypasses with production settings
FAQ## FAQ
Gunicorn on Windows?
Gunicorn is Unix-focused; use WSL/Linux VPS or Waitress for Windows experiments.
How many workers?
Start (2 × CPU cores) + 1; profile memory per worker—Django workers are heavier than Node.
Zero-downtime deploy?
Blue/green or rolling with two versions + load balancer—advanced; restart causes brief 502 on single node.
WhiteNoise without Nginx static?
Possible for small sites—Nginx still recommended for TLS and media.
Multiple Django sites on one server?
Separate systemd units, ports/sockets, and Nginx server_name blocks.