Deploying Django to Production: Gunicorn, Nginx, and systemd
Step-by-step production deployment for Django — Gunicorn WSGI server, Nginx reverse proxy, systemd service management, static files, SSL, and the configuration decisions that matter.
The standard Django production stack on Linux is: Gunicorn as the WSGI server, Nginx as the reverse proxy, and systemd to manage processes. This is the setup I’ve used across multiple production deployments.
Architecture
Internet
│
▼
Nginx (port 80/443)
│ ─── static files served directly
│ ─── /ws/ forwarded with Upgrade header (WebSocket)
│ ─── everything else proxied
▼
Gunicorn (Unix socket /run/gunicorn.sock)
│
▼
Django (WSGI/ASGI application)
Nginx handles SSL termination, static files, and connection management. Gunicorn handles Python. They communicate via a Unix socket (faster than a TCP port for local connections).
Prerequisites
# Ubuntu/Debian
sudo apt update && sudo apt install -y nginx python3-venv python3-dev
sudo apt install -y build-essential libpq-dev # for psycopg2
# Create app user (don't run as root)
sudo adduser --system --group deploy
Project Setup
# Directory structure
/home/deploy/
└── myproject/
├── venv/
├── myproject/ # Django project root
│ ├── settings/
│ │ ├── base.py
│ │ └── production.py
│ ├── wsgi.py
│ └── asgi.py
├── staticfiles/ # collectstatic output
└── .env
# As deploy user
git clone <repo> /home/deploy/myproject
cd /home/deploy/myproject
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
pip install gunicorn
# Collect static files
DJANGO_SETTINGS_MODULE=myproject.settings.production python manage.py collectstatic --noinput
# Run migrations
DJANGO_SETTINGS_MODULE=myproject.settings.production python manage.py migrate
Production Settings
# myproject/settings/production.py
from .base import *
DEBUG = False
ALLOWED_HOSTS = ["yourdomain.com", "www.yourdomain.com"]
STATIC_ROOT = "/home/deploy/myproject/staticfiles"
STATIC_URL = "/static/"
# Security
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
# Logging
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"file": {
"level": "WARNING",
"class": "logging.FileHandler",
"filename": "/var/log/django/app.log",
},
},
"loggers": {
"django": {
"handlers": ["file"],
"level": "WARNING",
"propagate": True,
},
},
}
Gunicorn Configuration
# gunicorn.conf.py — place in project root
import multiprocessing
# Workers: (2 × CPU cores) + 1 is the standard formula
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync" # use "uvicorn.workers.UvicornWorker" for ASGI/Channels
timeout = 120
bind = "unix:/run/gunicorn.sock"
user = "deploy"
group = "www-data"
loglevel = "info"
accesslog = "/var/log/gunicorn/access.log"
errorlog = "/var/log/gunicorn/error.log"
Test it manually before wiring up systemd:
source /home/deploy/myproject/venv/bin/activate
gunicorn -c /home/deploy/myproject/gunicorn.conf.py myproject.wsgi:application
Gunicorn systemd Service
# /etc/systemd/system/gunicorn.service
[Unit]
Description=Gunicorn daemon for myproject
After=network.target
[Service]
User=deploy
Group=www-data
WorkingDirectory=/home/deploy/myproject
Environment="DJANGO_SETTINGS_MODULE=myproject.settings.production"
EnvironmentFile=/home/deploy/myproject/.env
ExecStart=/home/deploy/myproject/venv/bin/gunicorn \
-c /home/deploy/myproject/gunicorn.conf.py \
myproject.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
ExecStop=/bin/kill -s TERM $MAINPID
PrivateTmp=true
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable gunicorn
sudo systemctl start gunicorn
sudo systemctl status gunicorn
Reload without downtime (zero-downtime restarts — sends HUP to gracefully cycle workers):
sudo systemctl reload gunicorn
View logs:
sudo journalctl -u gunicorn -f # follow live
sudo journalctl -u gunicorn --since "1 hour ago"
sudo journalctl -u gunicorn -n 50 # last 50 lines
Nginx Configuration
# /etc/nginx/sites-available/myproject
upstream django_app {
server unix:/run/gunicorn.sock fail_timeout=0;
}
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com www.yourdomain.com;
# SSL — managed by Certbot or manually
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Static files — served directly, bypass Gunicorn entirely
location /static/ {
alias /home/deploy/myproject/staticfiles/;
expires 30d;
add_header Cache-Control "public, immutable";
}
location /media/ {
alias /home/deploy/myproject/mediafiles/;
}
location = /favicon.ico {
access_log off;
log_not_found off;
}
# WebSocket support (required for Django Channels)
location /ws/ {
proxy_pass http://django_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400; # 24h — keep WebSocket alive
}
# Everything else goes to Gunicorn
location / {
proxy_pass http://django_app;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_redirect off;
# Handle large file uploads
client_max_body_size 20M;
}
}
# Enable the site
sudo ln -s /etc/nginx/sites-available/myproject /etc/nginx/sites-enabled/
# Test configuration
sudo nginx -t
# Reload
sudo systemctl reload nginx
SSL with Let’s Encrypt
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
# Auto-renewal (verify it works)
sudo certbot renew --dry-run
Certbot automatically edits your Nginx config and sets up a cron/systemd timer for renewal.
Celery systemd Service
If you’re using Celery, wire it up as a separate service:
# /etc/systemd/system/celery.service
[Unit]
Description=Celery worker for myproject
After=network.target
[Service]
User=deploy
Group=www-data
WorkingDirectory=/home/deploy/myproject
Environment="DJANGO_SETTINGS_MODULE=myproject.settings.production"
EnvironmentFile=/home/deploy/myproject/.env
ExecStart=/home/deploy/myproject/venv/bin/celery \
-A myproject worker \
-Q default \
-c 4 \
--loglevel=INFO \
--logfile=/var/log/celery/worker.log
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
# /etc/systemd/system/celery-beat.service
[Unit]
Description=Celery beat scheduler for myproject
After=network.target
[Service]
User=deploy
Group=www-data
WorkingDirectory=/home/deploy/myproject
Environment="DJANGO_SETTINGS_MODULE=myproject.settings.production"
EnvironmentFile=/home/deploy/myproject/.env
ExecStart=/home/deploy/myproject/venv/bin/celery \
-A myproject beat \
-l INFO \
--scheduler django_celery_beat.schedulers:DatabaseScheduler \
--logfile=/var/log/celery/beat.log
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
One beat process only. Never run two beat instances — every scheduled task fires twice. If you’re deploying to multiple servers, run beat on exactly one of them, or use a distributed lock.
sudo systemctl enable celery celery-beat
sudo systemctl start celery celery-beat
Deployment Script
A minimal deploy script for pushing updates:
#!/bin/bash
# deploy.sh — run on the server
set -e
APP_DIR="/home/deploy/myproject"
VENV="$APP_DIR/venv/bin"
cd "$APP_DIR"
git pull origin main
source "$VENV/activate"
pip install -r requirements.txt --quiet
DJANGO_SETTINGS_MODULE=myproject.settings.production \
python manage.py migrate --noinput
DJANGO_SETTINGS_MODULE=myproject.settings.production \
python manage.py collectstatic --noinput
sudo systemctl reload gunicorn
sudo systemctl restart celery celery-beat
echo "Deploy complete."
systemctl reload gunicorn sends SIGHUP — Gunicorn replaces workers one by one with no downtime.
Log Locations
| Service | Log |
|---|---|
| Nginx access | /var/log/nginx/access.log |
| Nginx error | /var/log/nginx/error.log |
| Gunicorn | journalctl -u gunicorn or configured log file |
| Celery worker | /var/log/celery/worker.log |
| Django app | /var/log/django/app.log |
Systemd Quick Reference
sudo systemctl start <service> # start
sudo systemctl stop <service> # stop
sudo systemctl restart <service> # stop + start (brief downtime)
sudo systemctl reload <service> # graceful reload (no downtime)
sudo systemctl status <service> # current state
sudo systemctl enable <service> # start on boot
sudo systemctl disable <service> # remove from boot
sudo journalctl -u <service> -f # follow logs
sudo journalctl -u <service> -n 100 # last 100 lines