← Writing
tutorial advanced

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.

#django#gunicorn#nginx#systemd#deployment#linux#production

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

ServiceLog
Nginx access/var/log/nginx/access.log
Nginx error/var/log/nginx/error.log
Gunicornjournalctl -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