Moving a Django application from local development to production is where real engineering challenges emerge. Deploying Django to production: best practices explained is not just about pushing code to a server—it's about configuring security, performance, and reliability so your team sleeps at night.
We've learned these lessons across multiple high-traffic SaaS and e-commerce platforms. In this guide, we'll walk through the critical decisions you need to make before go-live, from application servers to static file handling to environment configuration.
Gunicorn behind Nginx or a load balancer
Django's development server (python manage.py runserver) is single-threaded and blocks on I/O. It will collapse under any real traffic. We use Gunicorn as our application server because it spawns multiple worker processes, handles concurrent requests, and plays well with process managers like systemd or supervisord.
Gunicorn does not serve HTTP directly in production. Instead, we place Nginx in front of it as a reverse proxy. Nginx handles SSL termination, request buffering, compression, and serves as a load balancer across multiple Gunicorn workers. Here's a realistic setup:
# gunicorn_config.py
import multiprocessing
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync" # or "uvicorn.workers.UvicornWorker" for async
bind = "127.0.0.1:8000"
max_requests = 1000
max_requests_jitter = 50
timeout = 30
access_log_format = '%({X-Forwarded-For}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(q)s" "%(D)s"'
On the server, we'd run Gunicorn with this config:
gunicorn myapp.wsgi:application -c gunicorn_config.py
Nginx sits in front, terminating TLS and proxying requests:
upstream gunicorn_app {
server 127.0.0.1:8000;
server 127.0.0.1:8001;
server 127.0.0.1:8002;
}
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
location / {
proxy_pass http://gunicorn_app;
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_read_timeout 30s;
}
}
If you're on AWS or a cloud provider with managed load balancers, use those instead. The principle is the same: never expose Gunicorn directly to the internet. Always put a reverse proxy in front.
Collectstatic to S3 or WhiteNoise
Static files—JavaScript, CSS, images—cannot be served by Gunicorn in production. Django's collectstatic command gathers all static files from your apps and your project root into a single directory. You then need a strategy to serve them efficiently.
For most teams, we recommend one of two approaches:
Option 1: S3 + CloudFront works well if you're already on AWS. Run collectstatic as part of your deployment pipeline, configured to upload directly to S3 using django-storages:
# settings/production.py
import os
from storages.backends.s3boto3 import S3Boto3Storage
STATIC_URL = 'https://cdn.example.com/'
STATIC_ROOT = 'staticfiles'
if os.getenv('USE_S3'):
STORAGES = {
'default': {
'BACKEND': 'storages.backends.s3boto3.S3Boto3Storage',
},
'staticfiles': {
'BACKEND': 'storages.backends.s3boto3.S3StaticStorage',
},
}
AWS_STORAGE_BUCKET_NAME = 'my-app-static'
AWS_S3_REGION_NAME = 'us-east-1'
AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com'
STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/'
Option 2: WhiteNoise is simpler for smaller deployments. It serves static files directly from your application with excellent caching headers. Install it, add it to middleware, and you're done:
pip install whitenoise
# settings/production.py
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', # Add here
# ... rest of middleware
]
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
With WhiteNoise, your single Gunicorn process handles static files with aggressive caching. For most APIs and small-to-medium sites, this is sufficient. For high-traffic content-heavy sites, use S3.
DEBUG off, ALLOWED_HOSTS set
This seems obvious, but we've seen it missed in real deployments. DEBUG=True in production is catastrophic: it exposes your entire source code, environment variables, and database queries in error pages. Any exception becomes an information disclosure.
Set up environment-specific settings files:
# settings/base.py
DEBUG = False
ALLOWED_HOSTS = []
SECRET_KEY = os.getenv('SECRET_KEY')
# settings/production.py
from .base import *
ALLOWED_HOSTS = ['api.example.com', 'www.example.com']
DEBUG = False
# Only log errors in production
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'file': {
'level': 'ERROR',
'class': 'logging.handlers.RotatingFileHandler',
'filename': '/var/log/django/error.log',
},
},
'loggers': {
'django': {
'handlers': ['file'],
'level': 'ERROR',
},
},
}
Load it in your WSGI file:
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myapp.settings.production')
Use environment variables for secrets, never commit them to version control. Services like AWS Secrets Manager, HashiCorp Vault, or even a simple .env file (excluded from git) work. We prefer Secrets Manager for teams at scale.
Run migrations as a release step
Database migrations must run reliably as part of your deployment process, not manually afterward. Most teams use a "release" step in their CI/CD pipeline that runs before new code is live.
On Heroku, this is built-in. On your own servers, wrap it in your deployment script:
#!/bin/bash
set -e
# Pull latest code
git pull origin main
# Install dependencies
pip install -r requirements.txt
# Run migrations
python manage.py migrate --noinput
# Collect static files
python manage.py collectstatic --noinput
# Restart Gunicorn
sudo systemctl restart gunicorn
The --noinput flag makes migrations non-interactive, essential for automated pipelines. Always test migrations locally first. For large tables, use zero-downtime migration strategies: add the column, deploy code that handles both the old and new column, then backfill, then remove the old column in a second deployment.
Consider using Django's RunPython migration operations for data transformations that are safer than raw SQL:
from django.db import migrations
def set_defaults(apps, schema_editor):
User = apps.get_model('auth', 'User')
User.objects.filter(profile_completed=None).update(profile_completed=False)
class Migration(migrations.Migration):
dependencies = [
('myapp', '0001_previous_migration'),
]
operations = [
migrations.RunPython(set_defaults),
]
Common mistakes
We've seen teams skip the max_requests setting in Gunicorn, leading to gradual memory leaks over weeks. Set max_requests = 1000 so workers restart periodically.
Another mistake: not setting X-Forwarded-Proto headers in Nginx. Django checks request.is_secure() to determine if HTTPS was used. If Nginx doesn't forward this header, Django thinks every request is insecure, breaking CSRF protection and redirects.
Forgetting to set SECURE_SSL_REDIRECT = True in production settings. This forces all traffic to HTTPS automatically.
Running migrations with --fake-initial in production out of desperation. It causes consistency problems. Always test your migration strategy in a staging environment that mirrors production.
Key takeaways
- Always run Gunicorn behind Nginx or a load balancer. Never expose the application server directly.
- Serve static files from S3 + CloudFront for large deployments, or use WhiteNoise for simplicity.
- Set
DEBUG=False, configureALLOWED_HOSTS, and keepSECRET_KEYin environment variables. - Automate migrations as a release step in your deployment pipeline, tested in staging first.
- Configure proper logging, use Nginx headers for security, and set Gunicorn worker limits to prevent memory bloat.






Comments
Loading comments…