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
sudo apt update
sudo apt install python3 python3-pip python3-venv \
postgresql postgresql-contrib \
nginx \
git \
certbot python3-certbot-nginxStep 2: create a non-root user for the app
Never run Django as root.
sudo adduser --system --group --shell /bin/bash django
sudo mkdir -p /home/django
sudo chown django:django /home/djangoStep 3: set up PostgreSQL
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
EOFVerify connection:
psql -h localhost -U myapp -d myapp_productionStep 4: clone the repo and create a virtualenv
Switch to the django user for all subsequent steps:
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.txtMake 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 settingsStep 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.
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).
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='').split(',')
# In .env: ALLOWED_HOSTS=yourdomain.com,www.yourdomain.comSECRET_KEY
Never commit the SECRET_KEY. Generate a fresh one for production:
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"Store in .env:
SECRET_KEY = config('SECRET_KEY')Database config
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
# 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
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)
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:
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 accesscheck --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:
gunicorn myapp.wsgi --bind 127.0.0.1:8000 --workers 3Visit 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:
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):
[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.targetEnable and start:
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:
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:
sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
sudo nginx -t # test config
sudo systemctl reload nginxVisit http://yourdomain.com — should load your Django app.
Step 9: HTTPS with Let's Encrypt
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.comCertbot automatically updates the nginx config for HTTPS, sets up the renewal timer, and redirects HTTP to HTTPS.
Test auto-renewal:
sudo certbot renew --dry-runStep 10: deploy workflow
Going forward, deploys look like this:
# 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 restartAutomate 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 --deployshows no warningsDEBUG=Falsein production.envSECRET_KEYis unique to production (not committed)ALLOWED_HOSTSconfigured 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
- DEBUG=True in production. The #1 Django misconfiguration. Leaks secrets in error pages.
- ALLOWED_HOSTS = ['*']. Accepts any Host header — host-header attacks possible.
- Gunicorn bound to 0.0.0.0. Now directly exposed to the internet, bypassing nginx. Bind to 127.0.0.1 only.
- Static files served by Gunicorn / Django. Very slow — Django is not a static file server. Use nginx or WhiteNoise.
- Missing `python manage.py migrate` on deploy. New fields don't exist in the DB → 500 errors.
- Missing `collectstatic`. CSS / JS / images 404.
- Running as root. If Django gets compromised, attacker has root. Always run as a dedicated unprivileged user.
- No process manager. Plain
gunicorn &doesn't survive reboots or crashes. systemd is the minimum. - Forgetting `CONN_MAX_AGE`. Django opens a new DB connection per request by default. Setting
CONN_MAX_AGE=60keeps connections open — roughly 2-3× faster responses on database-heavy views. - `timezone.now()` + naive datetime. Make sure
USE_TZ = Truein 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.