Why Django Deployment Matters More Than You Think
Building a Django application is only half the job. Deploying it to production — reliably, securely, and with zero downtime — is where many projects stumble. A poorly deployed Django app is slow, insecure, and crashes under load.
This guide covers the production deployment stack I use for every client project: Docker, Gunicorn, Nginx, PostgreSQL, Let's Encrypt SSL, and GitHub Actions CI/CD. Whether you deploy to DigitalOcean, Hetzner, or AWS, the principles are the same.
Production Stack Overview
- Django + Gunicorn: application server
- Nginx: reverse proxy, static files, SSL termination
- PostgreSQL: production database
- Redis: caching and Celery broker
- Docker + Docker Compose: containerization
- GitHub Actions: CI/CD pipeline
- Let's Encrypt: free SSL certificates
Step 1: Prepare Django for Production
Before deploying, configure these critical settings:
# settings/production.py
import os
DEBUG = False
ALLOWED_HOSTS = os.environ['ALLOWED_HOSTS'].split(',')
SECRET_KEY = os.environ['SECRET_KEY']
# Security headers
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Static files
STATIC_ROOT = '/app/staticfiles'
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# Database
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ['DB_NAME'],
'USER': os.environ['DB_USER'],
'PASSWORD': os.environ['DB_PASSWORD'],
'HOST': os.environ['DB_HOST'],
'PORT': os.environ.get('DB_PORT', '5432'),
}
}
Security Checklist
Run the Django deployment checklist before going live:
python manage.py check --deploy
This catches common security misconfigurations like DEBUG=True, missing HSTS headers, and insecure cookie settings.
Step 2: Create the Dockerfile
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
libpq-dev gcc && \
rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy project
COPY . .
# Collect static files
RUN python manage.py collectstatic --noinput
# Run with Gunicorn
CMD ["gunicorn", "config.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "3", \
"--timeout", "120"]
Step 3: Docker Compose for the Full Stack
version: '3.8'
services:
web:
build: .
env_file: .env
depends_on:
- db
- redis
volumes:
- static:/app/staticfiles
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
redis:
image: redis:7-alpine
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
- static:/app/staticfiles:ro
- certs:/etc/letsencrypt:ro
volumes:
pgdata:
static:
certs:
Step 4: Configure Nginx
server {
listen 80;
server_name yourdomain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
location /static/ {
alias /app/staticfiles/;
expires 30d;
add_header Cache-Control "public, immutable";
}
location / {
proxy_pass http://web: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;
}
}
Step 5: Set Up CI/CD with GitHub Actions
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: |
pip install -r requirements.txt
python manage.py test
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /opt/myproject
git pull origin main
docker compose build
docker compose up -d
docker compose exec web python manage.py migrate
Step 6: Gunicorn Configuration
# gunicorn.conf.py
import multiprocessing
bind = "0.0.0.0:8000"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "gthread"
threads = 2
timeout = 120
keepalive = 5
max_requests = 1000
max_requests_jitter = 50
accesslog = "-"
errorlog = "-"
The max_requests setting restarts workers after 1,000 requests, preventing memory leaks. worker_class = "gthread" handles concurrent requests better than the default sync worker.
Step 7: Database Backups
Automate daily PostgreSQL backups:
# backup.sh - run via cron daily
#!/bin/bash
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
docker compose exec -T db pg_dump -U $DB_USER $DB_NAME | \
gzip > /backups/db_${TIMESTAMP}.gz
# Keep last 30 days
find /backups -name "db_*.gz" -mtime +30 -delete
Hosting Costs
- DigitalOcean Droplet (2GB RAM): $12/month — handles 5,000-10,000 daily visitors
- Hetzner VPS (4GB RAM): EUR 6/month — best value in Europe
- AWS (EC2 t3.small): ~$18/month — more complex setup but scalable
- Railway/Render: $5-20/month — managed platforms, less control
For most Django projects with under 50,000 monthly visitors, a EUR 10-15/month VPS is sufficient. I deploy all my client projects using this stack, and it handles production traffic reliably. See my backend development services for deployment assistance.
Need help deploying your Django application? I handle the full stack: Docker, CI/CD, SSL, monitoring, and ongoing maintenance.
Get in touch →