Tutorial

Deploy Django on a VPS: Gunicorn, Nginx, PostgreSQL, and SSL

April 03, 2026

Back to Blog

Django Deployment Architecture

Django's built-in development server (manage.py runserver) is explicitly not designed for production use — it is single-threaded, unoptimized, and has not been audited for security. A production Django deployment requires a proper WSGI server (Gunicorn), a reverse proxy (Nginx), a production database (PostgreSQL), and careful configuration of static file serving, environment variables, and process management.

Browser
Client Request
Nginx
:443 SSL + Static
Gunicorn
WSGI Server
Django
Application
PostgreSQL
Database

Each layer in this architecture serves a specific purpose. Nginx handles SSL termination, serves static files directly (CSS, JS, images) without touching Django, and buffers slow client connections so Gunicorn workers are freed quickly. Gunicorn manages multiple worker processes to handle concurrent requests. Django processes the business logic and database queries. PostgreSQL stores the data reliably with ACID compliance.

Why not just Gunicorn alone? Gunicorn can serve HTTP directly, but it is not designed to handle slow clients, SSL, static files, or DDoS protection. Nginx excels at all of these. The combination of Nginx + Gunicorn is the standard production stack used by Instagram, Pinterest, Mozilla, and thousands of other Django deployments.

Server Preparation

System Updates and Python Installation

$ sudo apt update && sudo apt upgrade -y
$ sudo apt install -y python3 python3-pip python3-venv python3-dev \
  build-essential libpq-dev nginx

$ python3 --version
Python 3.12.3

Create a Dedicated User

Never run Django as root. Create a dedicated system user with limited privileges:

$ sudo useradd -m -s /bin/bash -d /home/django django
$ sudo mkdir -p /var/www/myproject
$ sudo chown django:django /var/www/myproject

PostgreSQL Database Setup

PostgreSQL is the recommended database for Django in production. It offers superior performance, reliability, and full support for Django's advanced features like JSONField, ArrayField, full-text search, and database-level constraints.

$ sudo apt install -y postgresql postgresql-contrib
$ sudo systemctl start postgresql

$ sudo -u postgres psql
postgres=# CREATE DATABASE myproject;
postgres=# CREATE USER djangouser WITH PASSWORD 'secure-password-here';
postgres=# ALTER ROLE djangouser SET client_encoding TO 'utf8';
postgres=# ALTER ROLE djangouser SET default_transaction_isolation TO 'read committed';
postgres=# ALTER ROLE djangouser SET timezone TO 'UTC';
postgres=# GRANT ALL PRIVILEGES ON DATABASE myproject TO djangouser;
postgres=# \q
Django-recommended PostgreSQL settings: The three ALTER ROLE SET commands above are directly from Django's official documentation. They ensure consistent encoding (UTF-8), transaction isolation (read committed), and timezone (UTC) for all connections from this user.

Django Application Setup

1

Clone and Create Virtual Environment

$ sudo -u django -i
django$ cd /var/www/myproject
django$ git clone https://github.com/youruser/myproject.git .

django$ python3 -m venv venv
django$ source venv/bin/activate
(venv) django$ pip install -r requirements.txt
Successfully installed Django-5.1 gunicorn-23.0 psycopg2-binary-2.9 ...
2

Configure Environment Variables

Production secrets should never be in your codebase. Use a .env file with python-dotenv or environment variables:

(venv) django$ nano .env

DEBUG=False
SECRET_KEY=your-50-character-random-secret-key-here
ALLOWED_HOSTS=example.com,www.example.com
DATABASE_URL=postgresql://djangouser:secure-password-here@localhost:5432/myproject
STATIC_ROOT=/var/www/myproject/staticfiles
MEDIA_ROOT=/var/www/myproject/media
3

Production Settings

Your settings.py must be configured for production security and performance:

# settings.py — Production configuration
import os
from pathlib import Path
from dotenv import load_dotenv

load_dotenv()

BASE_DIR = Path(__file__).resolve().parent.parent

DEBUG = os.getenv("DEBUG", "False") == "True"
SECRET_KEY = os.getenv("SECRET_KEY")
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")

DATABASES = {
  "default": {
    "ENGINE": "django.db.backends.postgresql",
    "NAME": "myproject",
    "USER": "djangouser",
    "PASSWORD": os.getenv("DB_PASSWORD"),
    "HOST": "localhost",
    "PORT": "5432",
  }
}

STATIC_URL = "/static/"
STATIC_ROOT = os.getenv("STATIC_ROOT", BASE_DIR / "staticfiles")
MEDIA_URL = "/media/"
MEDIA_ROOT = os.getenv("MEDIA_ROOT", BASE_DIR / "media")

# Security settings for production
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
Critical production settings: DEBUG = False is mandatory — Debug mode exposes sensitive information (settings, environment variables, full stack traces) in error pages. SECRET_KEY must be a unique, unpredictable string at least 50 characters long. Never reuse the key from development.
4

Run Migrations and Collect Static Files

(venv) django$ python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, sessions, myapp
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
...

(venv) django$ python manage.py collectstatic --noinput
128 static files copied to './staticfiles'.

(venv) django$ python manage.py createsuperuser
Username: admin
Email: [email protected]
Password: ********
Superuser created successfully.

Gunicorn: The WSGI Server

Gunicorn (Green Unicorn) is a Python WSGI HTTP server that manages worker processes to handle concurrent requests. It is the most popular choice for production Django deployments due to its simplicity, reliability, and performance.

Testing Gunicorn

(venv) django$ gunicorn --bind 0.0.0.0:8000 myproject.wsgi:application
[INFO] Starting gunicorn 23.0.0
[INFO] Listening at: http://0.0.0.0:8000
[INFO] Using worker: sync
[INFO] Booting worker with pid: 12847

Production Gunicorn Configuration

For production, create a Gunicorn configuration file that defines worker count, binding, logging, and timeouts:

$ nano /var/www/myproject/gunicorn.conf.py

# Gunicorn production configuration
import multiprocessing

bind = "unix:/var/www/myproject/gunicorn.sock"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync"
worker_connections = 1000
timeout = 30
keepalive = 2

# Logging
accesslog = "/var/log/gunicorn/access.log"
errorlog = "/var/log/gunicorn/error.log"
loglevel = "warning"

# Process naming
proc_name = "myproject"

# Security
limit_request_line = 4094
limit_request_fields = 100
limit_request_field_size = 8190
Worker count formula: The recommended number of Gunicorn workers is (2 x CPU_CORES) + 1. For a 4-core VPS, that means 9 workers. Each worker is an independent process that handles one request at a time (with sync workers). More workers mean more concurrent request capacity but also more memory usage — each worker typically consumes 50-150MB.

Systemd Service for Gunicorn

To ensure Gunicorn starts automatically on boot and restarts on failure, create a systemd service file:

$ sudo nano /etc/systemd/system/gunicorn.service

[Unit]
Description=Gunicorn daemon for myproject
Requires=gunicorn.socket
After=network.target

[Service]
Type=notify
User=django
Group=django
RuntimeDirectory=gunicorn
WorkingDirectory=/var/www/myproject
ExecStart=/var/www/myproject/venv/bin/gunicorn \
  --config gunicorn.conf.py \
  myproject.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
Restart=on-failure
RestartSec=5
KillMode=mixed
PrivateTmp=true

[Install]
WantedBy=multi-user.target
$ sudo mkdir -p /var/log/gunicorn
$ sudo chown django:django /var/log/gunicorn
$ sudo systemctl daemon-reload
$ sudo systemctl start gunicorn
$ sudo systemctl enable gunicorn
Created symlink ... → gunicorn.service

$ sudo systemctl status gunicorn
● gunicorn.service - Gunicorn daemon for myproject
Active: active (running)
Main PID: 15234 (gunicorn)
Tasks: 10 (limit: 4654)

Nginx Reverse Proxy Configuration

Nginx sits in front of Gunicorn, handling SSL termination, serving static and media files directly, and proxying dynamic requests to Gunicorn via the Unix socket:

$ sudo nano /etc/nginx/sites-available/myproject

upstream django_backend {
  server unix:/var/www/myproject/gunicorn.sock fail_timeout=0;
}

server {
  listen 80;
  server_name example.com www.example.com;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl http2;
  server_name example.com www.example.com;

  ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

  client_max_body_size 20M;

  # Static files — served directly by Nginx
  location /static/ {
    alias /var/www/myproject/staticfiles/;
    expires 30d;
    add_header Cache-Control "public, immutable";
  }

  # Media files — user uploads
  location /media/ {
    alias /var/www/myproject/media/;
    expires 7d;
  }

  # Everything else goes to Django via Gunicorn
  location / {
    proxy_pass http://django_backend;
    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_redirect off;
  }
}
$ sudo ln -s /etc/nginx/sites-available/myproject /etc/nginx/sites-enabled/
$ sudo nginx -t
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ sudo systemctl reload nginx
Static file performance: Serving static files through Nginx instead of Django is crucial for performance. Nginx can serve a static file in microseconds with zero Python overhead, while Django would need to start a WSGI worker, load the file into Python memory, and stream it back through the framework stack. For a typical Django site, 80%+ of requests are for static assets.

SSL with Let's Encrypt

$ sudo apt install certbot python3-certbot-nginx -y
$ sudo certbot --nginx -d example.com -d www.example.com
Successfully received certificate.

$ sudo certbot renew --dry-run
Congratulations, all simulated renewals succeeded

Deployment Automation Script

Automate your deployment process with a script that handles code updates, dependency installation, migrations, static file collection, and service restart:

$ nano /var/www/myproject/deploy.sh

#!/bin/bash
set -e

PROJECT_DIR="/var/www/myproject"
cd "$PROJECT_DIR"

echo "Pulling latest code..."
git pull origin main

echo "Activating virtual environment..."
source venv/bin/activate

echo "Installing dependencies..."
pip install -r requirements.txt --quiet

echo "Running migrations..."
python manage.py migrate --noinput

echo "Collecting static files..."
python manage.py collectstatic --noinput

echo "Restarting Gunicorn..."
sudo systemctl restart gunicorn

echo "Deployment complete!"
$ chmod +x deploy.sh
$ ./deploy.sh
Pulling latest code...
Already up to date.
Installing dependencies...
Running migrations...
No migrations to apply.
Collecting static files...
0 static files copied, 128 unmodified.
Restarting Gunicorn...
Deployment complete!

Troubleshooting Common Issues

502 Bad Gateway

Cause: Gunicorn is not running, or the socket file does not exist or has wrong permissions.

Solution: Check systemctl status gunicorn and verify the socket file exists at the path specified in the Nginx upstream. Ensure the Nginx user (www-data) can access the socket.

Static files return 404

Cause: collectstatic was not run, or the Nginx alias path does not match STATIC_ROOT.

Solution: Run python manage.py collectstatic and verify the path in Nginx matches exactly with the trailing slash.

DisallowedHost error

Cause: The domain is not in Django's ALLOWED_HOSTS setting.

Solution: Add the exact domain and any aliases to ALLOWED_HOSTS in settings.py. Include both example.com and www.example.com.

Gunicorn workers timing out

Cause: A view takes longer than the timeout value (default 30s).

Solution: Optimize the slow view (database queries, external API calls) or increase the timeout in gunicorn.conf.py. Consider using async workers (worker_class = "gevent") for I/O-bound workloads.

Django Deployment with Panelica

Panelica supports Python deployments through Docker containers — deploy Django with a Docker Compose template that includes Gunicorn, your application code, and a linked PostgreSQL database, all managed from the panel. The Docker module provides pre-built templates for common Python frameworks, environment variable management through the panel interface, integrated log viewing, and automatic Nginx reverse proxy configuration with SSL. For developers who prefer a more hands-on approach, Panelica's reverse proxy mode lets you point any domain to a custom port where your Django application listens, with SSL, security headers, and static file serving handled automatically by the panel.

Production Checklist

  • DEBUG = False in production settings
  • Strong, unique SECRET_KEY loaded from environment
  • ALLOWED_HOSTS configured with exact domain names
  • PostgreSQL configured with Django-recommended settings
  • Virtual environment with pinned dependencies (requirements.txt)
  • Gunicorn with appropriate worker count and configuration file
  • Systemd service for automatic start and crash recovery
  • Nginx reverse proxy with static/media file serving
  • SSL certificate with auto-renewal via Certbot
  • Django security settings enabled (HSTS, secure cookies, CSP)
  • collectstatic run after every deployment
  • Database migrations applied before restarting Gunicorn
  • Application runs as non-root user with minimal privileges
  • Log files configured with rotation
Share: