You’ve built a Django app. It works on your laptop. Now you need to put it on the internet without it getting hacked, falling over under load, or leaking secrets in error pages.
This post is the deployment guide I wish I’d had the first time. We won’t cover every possible deploy target — instead we’ll focus on a battle-tested baseline (Gunicorn + Nginx + PostgreSQL on a Linux server or container) and the security/reliability checklist that applies regardless of where you deploy.
The deployment shape
A production Django stack typically looks like this:
[ Browser / Mobile App ]
│ HTTPS
▼
[ Nginx ] (reverse proxy, TLS, static files)
│ HTTP (localhost)
▼
[ Gunicorn ] (Python app server, workers running Django)
│
▼
[ PostgreSQL ] + [ Redis ] (cache, sessions, Celery broker)
Three layers: a reverse proxy (Nginx), an app server (Gunicorn), and your data stores (PostgreSQL, Redis). Each does one job well.
Step 1: Production-ready settings
Split your settings:
conquered/
├── settings/
│ ├── __init__.py
│ ├── base.py
│ ├── dev.py
│ └── prod.py
base.py has shared config. prod.py overrides it with secure defaults.
# settings/prod.py
from .base import *
import os
DEBUG = False
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
# Security
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
X_FRAME_OPTIONS = "DENY"
# 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.get("DB_HOST", "localhost"),
"PORT": os.environ.get("DB_PORT", "5432"),
"CONN_MAX_AGE": 60, # persistent connections
}
}
# Static files (whitenoise serves them; nginx caches them)
STATIC_ROOT = "/var/www/conquered/static"
MEDIA_ROOT = "/var/www/conquered/media"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
# Caching
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.environ.get("REDIS_URL", "redis://localhost:6379/1"),
}
}
# Logging
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {"format": "{levelname} {asctime} {module} {message}", "style": "{"},
},
"handlers": {
"console": {"class": "logging.StreamHandler", "formatter": "verbose"},
},
"root": {"handlers": ["console"], "level": "INFO"},
"loggers": {
"django.security": {"level": "WARNING"},
"django.request": {"level": "ERROR"},
},
}
Step 2: Run Django’s deployment check
Django has a built-in command that flags common issues:
DJANGO_SETTINGS_MODULE=conquered.settings.prod python manage.py check --deploy
It checks 20+ things — security headers, secret key strength, DEBUG, SECURE_SSL_REDIRECT, and more. Fix every warning before you deploy.
Step 3: Static files with WhiteNoise
pip install whitenoise
Add the middleware right after SecurityMiddleware:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
# ... the rest
]
WhiteNoise serves static files efficiently from your Django app, with proper caching headers and gzip/brotli compression. For a small site, this is all you need. For a larger site, put a CDN (Cloudflare, Fastly) in front.
Collect static files at deploy time:
python manage.py collectstatic --noinput
Step 4: Gunicorn
Gunicorn is the production WSGI server.
pip install gunicorn
Run it:
gunicorn conquered.wsgi:application \
--bind 127.0.0.1:8000 \
--workers 4 \
--worker-class sync \
--timeout 30 \
--access-logfile - \
--error-logfile -
Workers: start with 2 × CPU_cores + 1. Each worker is a separate process; they don’t share memory, so the ORM cache and any in-process state is per-worker.
Worker class:
sync(default): one request per worker at a time. Fine for normal Django.gevent/eventlet: greenlet-based; better when you have lots of waiting (slow upstream calls). Requires monkey-patching.gthread: threaded workers, somewhere in between.
For ASGI (websockets, async views), use uvicorn workers with a Uvicorn worker class.
Step 5: A systemd service
Don’t run Gunicorn in a tmux pane. Make it a real service.
# /etc/systemd/system/conquered.service
[Unit]
Description=Conquered Django app
After=network.target postgresql.service redis.service
[Service]
User=conquered
Group=conquered
WorkingDirectory=/opt/conquered
EnvironmentFile=/etc/conquered/env
ExecStart=/opt/conquered/.venv/bin/gunicorn conquered.wsgi:application \
--bind 127.0.0.1:8000 \
--workers 4 \
--timeout 30 \
--access-logfile /var/log/conquered/access.log \
--error-logfile /var/log/conquered/error.log
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable conquered
sudo systemctl start conquered
sudo systemctl status conquered
Now Gunicorn restarts on crash, starts at boot, and is fully managed by systemd.
Step 6: Nginx in front
Nginx terminates TLS, serves static files (or proxies them through), and forwards everything else to Gunicorn.
# /etc/nginx/sites-available/conquered
server {
listen 80;
server_name conquered.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name conquered.example.com;
ssl_certificate /etc/letsencrypt/live/conquered.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/conquered.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Security headers (defense in depth — Django sets some too)
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 25M;
location /static/ {
alias /var/www/conquered/static/;
expires 1y;
access_log off;
add_header Cache-Control "public, immutable";
}
location /media/ {
alias /var/www/conquered/media/;
expires 30d;
access_log off;
}
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 30s;
}
}
Use Certbot for free Let’s Encrypt TLS certs:
sudo certbot --nginx -d conquered.example.com
Certbot will modify your Nginx config to add the SSL cert paths and set up auto-renewal via a cron job.
Step 7: PostgreSQL
For setup, see How to Connect PostgreSQL with Django . For tuning, see PostgreSQL Fundamentals . A few production-specific notes:
- Run Postgres on a separate machine (or managed service: RDS, Cloud SQL, Crunchy Bridge) once you have non-trivial traffic.
- Set
CONN_MAX_AGE=60in Django to reuse connections (saves the cost of a new DB process per request). - For >50 workers across multiple app servers, put PgBouncer in front of Postgres.
transactionpool mode is the sweet spot for Django. - Set up automated backups. Test the restore.
Step 8: Background jobs with Celery
For anything slow or unreliable (sending email, processing uploads, calling external APIs), use a task queue:
pip install celery redis
# conquered/celery.py
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "conquered.settings.prod")
app = Celery("conquered")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
Add to settings/prod.py:
CELERY_BROKER_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = "django-db"
CELERY_TASK_ALWAYS_EAGER = False
Run a worker:
celery -A conquered worker --loglevel=info
Manage it with systemd, just like Gunicorn.
Step 9: Observability
Out-of-the-box logging is the bare minimum. Production-worthy observability includes:
- Error tracking — Sentry. Setup is one line of config; the time it saves the first time you have an unexpected
500is enormous. - Metrics — at minimum, request rates and durations from Nginx logs. Better: Prometheus + Grafana, or a hosted equivalent (Datadog, New Relic).
- Log aggregation — ship logs to a central place (Loki, Papertrail, CloudWatch). Grepping over SSH gets old fast.
- Uptime checks — UptimeRobot, BetterStack, Pingdom. Get a page when the site goes down.
Step 10: The deploy itself
Pick one of:
- Container-based (Docker + docker-compose, or Kubernetes for scale). See an upcoming post on Docker for Python developers .
- Bare metal / VPS (DigitalOcean, Hetzner, Linode) with the systemd setup above.
- PaaS (Fly.io, Railway, Render, Heroku) — write a
Procfile, push, done. Trades cost for simplicity. - Cloud-native (AWS Elastic Beanstalk, Google Cloud Run, Azure App Service).
The “right” answer depends on team size and traffic. For a small team or side project, a $10/month VPS with the stack above is genuinely fine for tens of thousands of users. Don’t over-engineer it.
The pre-flight checklist
Print this and run through it before every production deploy:
-
DEBUG = Falsein production settings -
ALLOWED_HOSTSset to your real domain(s) -
SECRET_KEYfrom environment, never committed - DB credentials from environment, never committed
- HTTPS enforced, HSTS header set
- Security cookies (
SESSION_COOKIE_SECURE,CSRF_COOKIE_SECURE) -
python manage.py check --deploypasses with no warnings - Database migrations applied (
python manage.py migrate) - Static files collected (
python manage.py collectstatic --noinput) - Gunicorn running under systemd (or container) with restart-on-failure
- Nginx proxying with TLS
- Backups configured and tested
- Sentry (or equivalent) wired up
- Uptime monitor pinging a
/health/endpoint
A /health/ endpoint
While we’re at it, add this — every monitoring tool wants it:
# blog/views.py
from django.db import connection
from django.http import JsonResponse
def health(request):
try:
with connection.cursor() as cur:
cur.execute("SELECT 1")
return JsonResponse({"status": "ok"})
except Exception as e:
return JsonResponse({"status": "error", "detail": str(e)}, status=503)
# urls.py
path("health/", health, name="health"),
Hit it from your monitoring tool. If the DB is down, you get alerted before users do.
Conclusion
Production isn’t about exotic tools — it’s about discipline with the basics. Sane settings, a real app server, a reverse proxy, a managed (or well-managed) database, and observability. Get those right and your Django app will run reliably for years with very little drama.
If you’re new to Django, start with Django Conquered: Project Setup . For more on the database layer, see Django ORM Deep Dive .
Happy shipping!
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .