Client Area

Django Production Deployment Checklist (Gunicorn + nginx + PostgreSQL)

ByDomain India Team
9 min read22 Apr 20262 views

In this article

  • 1Why Django needs a VPS, not shared hosting
  • 2Prerequisites
  • 3Step 1: system dependencies
  • 4Step 2: create a non-root user for the app
  • 5Step 3: set up PostgreSQL

Django Production Deployment Checklist (Gunicorn + nginx + PostgreSQL)

Deploying Django to production is not like deploying PHP. Django needs a long-running WSGI process, which shared hosting does not provide. This guide covers the full production setup on a VPS — Gunicorn, nginx, PostgreSQL, systemd, HTTPS, static file handling, and the settings-hardening checklist you should run through before going live.

Why Django needs a VPS, not shared hosting

Shared hosting runs PHP via the web server (Apache, LiteSpeed) — each request spawns a short-lived PHP process. Django does not work this way. Django is a long-running Python process that listens on a port; the web server proxies requests to it.

Shared hosting does not let you run long-lived processes. Our VPS hosting plans do — full root SSH, any OS, any service.

Prerequisites

  • VPS with Ubuntu 22.04 or later, or AlmaLinux 9 (other Linux distros work, adjust commands)
  • Root SSH access
  • Python 3.10+ (python3 --version)
  • A domain pointing to the VPS IP

Step 1: system dependencies

bash
sudo apt update
sudo apt install python3 python3-pip python3-venv \
                 postgresql postgresql-contrib \
                 nginx \
                 git \
                 certbot python3-certbot-nginx

Step 2: create a non-root user for the app

Never run Django as root.

bash
sudo adduser --system --group --shell /bin/bash django
sudo mkdir -p /home/django
sudo chown django:django /home/django

Step 3: set up PostgreSQL

bash
sudo -u postgres psql <<EOF
CREATE DATABASE myapp_production;
CREATE USER myapp WITH ENCRYPTED PASSWORD 'use-a-strong-password-here';
GRANT ALL PRIVILEGES ON DATABASE myapp_production TO myapp;
ALTER DATABASE myapp_production OWNER TO myapp;
\q
EOF

Verify connection:

bash
psql -h localhost -U myapp -d myapp_production

Step 4: clone the repo and create a virtualenv

Switch to the django user for all subsequent steps:

bash
sudo -u django bash
cd /home/django
git clone https://github.com/you/myapp.git
cd myapp

python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt

Make sure your requirements.txt pins versions and includes:

Django>=4.2,<5.1
gunicorn
psycopg[binary]
whitenoise          # for serving static files; optional
python-decouple     # or django-environ, for settings

Step 5: production settings hardening

The default settings.py is for development. Production needs changes.

Minimum checklist — every item must be addressed:

DEBUG = False

Running DEBUG=True in production is the single most common Django misconfiguration. It leaks your secret key, database config, full stack traces, and environment variables to anyone who triggers an error.

python
import os
from decouple import config

DEBUG = config('DEBUG', default=False, cast=bool)

Set DEBUG=False in your production .env. Never deploy with DEBUG=True.

ALLOWED_HOSTS

Django refuses to serve requests to hosts not in this list (when DEBUG=False).

python
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='').split(',')
# In .env: ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com

SECRET_KEY

Never commit the SECRET_KEY. Generate a fresh one for production:

bash
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Store in .env:

python
SECRET_KEY = config('SECRET_KEY')

Database config

python
DATABASES = {
    'default': {
        'ENGINE':   'django.db.backends.postgresql',
        'NAME':     config('DB_NAME'),
        'USER':     config('DB_USER'),
        'PASSWORD': config('DB_PASSWORD'),
        'HOST':     config('DB_HOST', default='localhost'),
        'PORT':     config('DB_PORT', default='5432'),
        'CONN_MAX_AGE': 60,     # persistent connections; don't reopen per request
    }
}

Security settings

python
# HTTPS enforcement
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

# Cookies
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE    = True
SESSION_COOKIE_HTTPONLY = True
CSRF_COOKIE_HTTPONLY    = True
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SAMESITE    = 'Lax'

# Content-Type sniffing defence
SECURE_CONTENT_TYPE_NOSNIFF = True

# X-Frame-Options
X_FRAME_OPTIONS = 'DENY'

See our Security Headers article for the broader context.

Static files

python
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'

# WhiteNoise for serving (alternative to nginx static serving)
MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware')
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'

Logging to file (production format)

python
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '[{asctime}] {levelname} {name} {message}',
            'style': '{',
        },
    },
    'handlers': {
        'file': {
            'level': 'INFO',
            'class': 'logging.handlers.RotatingFileHandler',
            'filename': '/home/django/myapp/logs/app.log',
            'maxBytes': 10 * 1024 * 1024,
            'backupCount': 5,
            'formatter': 'verbose',
        },
    },
    'loggers': {
        'django': { 'handlers': ['file'], 'level': 'INFO' },
        'myapp':  { 'handlers': ['file'], 'level': 'INFO' },
    },
}

Step 6: run migrations and collect static files

Still as the django user with venv active:

bash
mkdir -p logs
python manage.py check --deploy     # Django's built-in pre-deploy checklist
python manage.py migrate
python manage.py collectstatic --noinput
python manage.py createsuperuser     # optional, for admin access

check --deploy runs Django's built-in security audit and reports anything missing. It's quick and comprehensive — always run it before first deploy.

Step 7: Gunicorn configuration

Test Gunicorn manually:

bash
gunicorn myapp.wsgi --bind 127.0.0.1:8000 --workers 3

Visit http://your-vps-ip:8000 — should load (though CSS / static may be missing until nginx proxies).

Stop with Ctrl+C. Now set up as a systemd service.

Gunicorn config file

Create /home/django/myapp/gunicorn.conf.py:

python
import multiprocessing

bind = '127.0.0.1:8000'
workers = multiprocessing.cpu_count() * 2 + 1    # common formula
worker_class = 'sync'                             # use 'gthread' or 'uvicorn.workers.UvicornWorker' for async apps
timeout = 120
keepalive = 5
max_requests = 1000                               # restart worker after this many requests (mitigates memory leaks)
max_requests_jitter = 50
accesslog = '/home/django/myapp/logs/gunicorn-access.log'
errorlog  = '/home/django/myapp/logs/gunicorn-error.log'
loglevel  = 'info'

systemd service

Create /etc/systemd/system/myapp.service (as root):

ini
[Unit]
Description=Gunicorn instance for myapp
After=network.target postgresql.service

[Service]
User=django
Group=django
WorkingDirectory=/home/django/myapp
Environment="PATH=/home/django/myapp/venv/bin"
EnvironmentFile=/home/django/myapp/.env
ExecStart=/home/django/myapp/venv/bin/gunicorn --config gunicorn.conf.py myapp.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Enable and start:

bash
sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp
sudo systemctl status myapp      # should show: active (running)

Step 8: nginx reverse proxy

Create /etc/nginx/sites-available/myapp.conf:

nginx
server {
    listen 80;
    server_name yourdomain.com www.yourdomain.com;

    # Static files — let nginx serve, faster than Python
    location /static/ {
        alias /home/django/myapp/staticfiles/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    location /media/ {
        alias /home/django/myapp/media/;
        expires 30d;
    }

    # Everything else → Gunicorn
    location / {
        proxy_pass http://127.0.0.1:8000;
        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;
        proxy_read_timeout 120s;
    }

    client_max_body_size 20m;    # adjust for your upload size
}

Enable and test:

bash
sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
sudo nginx -t                    # test config
sudo systemctl reload nginx

Visit http://yourdomain.com — should load your Django app.

Step 9: HTTPS with Let's Encrypt

bash
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Certbot automatically updates the nginx config for HTTPS, sets up the renewal timer, and redirects HTTP to HTTPS.

Test auto-renewal:

bash
sudo certbot renew --dry-run

Step 10: deploy workflow

Going forward, deploys look like this:

bash
# On your laptop, push code
git push origin main

# SSH to the server
ssh [email protected]
cd myapp
git pull
source venv/bin/activate
pip install -r requirements.txt
python manage.py migrate
python manage.py collectstatic --noinput
sudo systemctl reload myapp         # graceful restart

Automate this with GitHub Actions — same pattern, adapted for VPS + systemd reload instead of cPanel rsync.

Pre-deploy checklist

Before flipping production traffic to a new deployment:

  • python manage.py check --deploy shows no warnings
  • DEBUG=False in production .env
  • SECRET_KEY is unique to production (not committed)
  • ALLOWED_HOSTS configured correctly
  • Database uses a dedicated DB user with least-privilege grants
  • Static files collected and served
  • HTTPS redirect tested
  • HSTS header visible (curl -I https://yourdomain.com)
  • Admin URL is not /admin/ (attackers scan /admin; rename to something like /control-panel/)
  • Logs are being written and rotated
  • Backup script in place (see Automated Backups)
  • Monitoring: Sentry or similar for error tracking

Common pitfalls

  1. DEBUG=True in production. The #1 Django misconfiguration. Leaks secrets in error pages.
  2. ALLOWED_HOSTS = ['*']. Accepts any Host header — host-header attacks possible.
  3. Gunicorn bound to 0.0.0.0. Now directly exposed to the internet, bypassing nginx. Bind to 127.0.0.1 only.
  4. Static files served by Gunicorn / Django. Very slow — Django is not a static file server. Use nginx or WhiteNoise.
  5. Missing `python manage.py migrate` on deploy. New fields don't exist in the DB → 500 errors.
  6. Missing `collectstatic`. CSS / JS / images 404.
  7. Running as root. If Django gets compromised, attacker has root. Always run as a dedicated unprivileged user.
  8. No process manager. Plain gunicorn & doesn't survive reboots or crashes. systemd is the minimum.
  9. Forgetting `CONN_MAX_AGE`. Django opens a new DB connection per request by default. Setting CONN_MAX_AGE=60 keeps connections open — roughly 2-3× faster responses on database-heavy views.
  10. `timezone.now()` + naive datetime. Make sure USE_TZ = True in settings and always use timezone-aware datetimes.

Frequently asked questions

Can I use SQLite in production?

For a tiny personal site with no writes during peak — maybe. For anything else, use PostgreSQL or MySQL. SQLite's write lock scales very poorly beyond a few concurrent users.

Should I use uWSGI instead of Gunicorn?

Gunicorn is simpler and sufficient for most Django apps. uWSGI is more configurable, but the extra complexity is rarely needed.

Do I need a Docker container?

No. systemd + venv is simpler and fine for single-server deployments. Docker becomes useful when you're running 5+ sites or deploying to orchestrated clusters.

How do I run management commands on production?

ssh in, cd myapp, source venv/bin/activate, python manage.py yourcommand. For scheduled commands use cron (see Cron Expression Guide). For data migrations, write a Django migration (not a custom command) so it's versioned with the code.

How do I scale beyond one server?

Add nginx load balancing to multiple Gunicorn instances, move PostgreSQL to a separate VPS, use Redis for session storage + caching, set up shared media storage (S3). Each step is its own topic.

What about Django Channels for WebSockets?

Channels uses ASGI, not WSGI — needs Daphne or Uvicorn instead of Gunicorn. Architecture is similar: ASGI server → nginx → client. Worth a separate guide.

Django or Flask?

Django for batteries-included apps (auth, admin, ORM). Flask for microservices or when you want to pick your own libraries. Both deploy the same way on a VPS (WSGI server + nginx + systemd).


Need help setting up Django on your DI VPS? [email protected] — we provide best-effort technical guidance on VPS-level deployments for our customers.

Was this article helpful?

Your feedback helps us improve our documentation

Still need help? Submit a support ticket