A Django application served by Gunicorn behind nginx on a 4 vCPU VPS handles 500-1,000 requests per second with sub-100ms response times for dynamic content. This guide covers the complete production stack: Gunicorn worker tuning, nginx reverse proxy, WhiteNoise static file serving, Celery task queues, Supervisor process management, and PostgreSQL connection pooling.
All configurations target Ubuntu 22.04 with Python 3.12, Django 5.1, and PostgreSQL 16. For VPS options, see the Pricing page.
Gunicorn Worker Configuration
Gunicorn is the standard WSGI server for Django production deployments. The number and type of workers determine your application's throughput capacity.
Worker count formula: `(2 x CPU cores) + 1` for synchronous workers. A 4 vCPU server runs 9 sync workers. If your application makes external API calls or database queries that block for over 100ms, use async workers with `gevent` or `uvicorn` for ASGI.
<h1>/etc/gunicorn/app.conf.py</h1>
import multiprocessing
<h1>Server socket</h1>
bind = "unix:/run/gunicorn/app.sock"
backlog = 2048
<h1>Worker processes</h1>
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "gthread"
threads = 4
worker_connections = 1000
max_requests = 2000
max_requests_jitter = 200
timeout = 120
graceful_timeout = 30
keepalive = 5
<h1>Logging</h1>
accesslog = "/var/log/gunicorn/access.log"
errorlog = "/var/log/gunicorn/error.log"
loglevel = "warning"
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s'
<h1>Process naming</h1>
proc_name = "myapp"
<h1>Security</h1>
limit_request_line = 4094
limit_request_fields = 100
limit_request_field_size = 8190
The `gthread` worker class uses threads within each process. With 9 workers and 4 threads each, the server handles 36 concurrent requests. This is more memory-efficient than pure process-based workers because threads share the process memory space.
`max_requests = 2000` with `max_requests_jitter = 200` recycles workers after 1,800-2,200 requests (randomized). This prevents all workers from recycling simultaneously and guards against memory leaks in long-running processes.
Nginx Reverse Proxy Configuration
upstream django_app {
server unix:/run/gunicorn/app.sock fail_timeout=0;
}
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
client_max_body_size 50M;
# Static files (served by nginx directly)
location /static/ {
alias /var/www/app/staticfiles/;
expires 365d;
add_header Cache-Control "public, immutable";
access_log off;
}
# Media files (user uploads)
location /media/ {
alias /var/www/app/media/;
expires 30d;
add_header Cache-Control "public";
}
# Django application
location / {
proxy_pass http://django_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_redirect off;
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 4 32k;
}
# Health check endpoint (no logging)
location /health/ {
proxy_pass http://django_app;
proxy_set_header Host $host;
access_log off;
}
}
Using a Unix socket (`unix:/run/gunicorn/app.sock`) instead of TCP (`127.0.0.1:8000`) for the Gunicorn upstream gives 10-15% better performance by eliminating TCP overhead for local communication.
WhiteNoise Static File Configuration
WhiteNoise serves static files directly from Gunicorn with compression and caching headers. For most deployments, this is simpler than configuring nginx to serve statics, and the performance difference is negligible when combined with a CDN.
<h1>settings/production.py</h1>
STATIC_URL = "/static/"
STATIC_ROOT = "/var/www/app/staticfiles"
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", # After SecurityMiddleware
# ... rest of middleware
]
<h1>WhiteNoise settings</h1>
WHITENOISE_MAX_AGE = 31536000 # 1 year for hashed files
WHITENOISE_MANIFEST_STRICT = False
<h1>Security settings for production</h1>
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 63072000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
<h1>Database with connection pooling</h1>
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": 600, # 10 minutes persistent connections
"CONN_HEALTH_CHECKS": True,
"OPTIONS": {
"connect_timeout": 5,
},
}
}
<h1>Cache configuration</h1>
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.environ.get("REDIS_URL", "redis://127.0.0.1:6379/0"),
"KEY_PREFIX": "app",
"OPTIONS": {
"db": 0,
},
}
}
<h1>Session configuration</h1>
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
`CONN_MAX_AGE = 600` keeps database connections open for 10 minutes between requests, eliminating connection setup overhead (typically 20-50ms per connection). With `CONN_HEALTH_CHECKS = True`, Django validates connections before use, preventing errors from stale connections.
Celery Task Queue Configuration
Celery handles background tasks like email sending, report generation, and scheduled jobs. Configure it with Redis as the broker and result backend:
<h1>celery_app.py</h1>
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
app = Celery("myapp")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
<h1>settings/production.py - Celery settings</h1>
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://127.0.0.1:6379/1")
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/2")
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_TIMEZONE = "UTC"
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 300
CELERY_TASK_SOFT_TIME_LIMIT = 240
CELERY_WORKER_MAX_TASKS_PER_CHILD = 1000
CELERY_WORKER_PREFETCH_MULTIPLIER = 4
<h1>Task routing</h1>
CELERY_TASK_ROUTES = {
"myapp.tasks.send_email": {"queue": "emails"},
"myapp.tasks.generate_report": {"queue": "reports"},
"myapp.tasks.*": {"queue": "default"},
}
<h1>Celery Beat schedule</h1>
CELERY_BEAT_SCHEDULE = {
"cleanup-expired-sessions": {
"task": "myapp.tasks.cleanup_sessions",
"schedule": 3600.0, # Every hour
},
"generate-daily-reports": {
"task": "myapp.tasks.daily_report",
"schedule": crontab(hour=2, minute=0), # 2 AM UTC
},
}
Task routing sends email tasks to a dedicated queue, preventing slow report generation from blocking email delivery. Each queue can have a different number of workers based on workload.
Supervisor Process Management
Supervisor manages Gunicorn and Celery processes, automatically restarting them if they crash:
; /etc/supervisor/conf.d/gunicorn.conf
[program:gunicorn]
command=/var/www/app/venv/bin/gunicorn config.wsgi:application -c /etc/gunicorn/app.conf.py
directory=/var/www/app/current
user=www-data
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/supervisor/gunicorn.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=10
environment=DJANGO_SETTINGS_MODULE="config.settings.production"
; /etc/supervisor/conf.d/celery-default.conf
[program:celery-default]
command=/var/www/app/venv/bin/celery -A config worker -l warning -Q default -c 4
directory=/var/www/app/current
user=www-data
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/supervisor/celery-default.log
stdout_logfile_maxbytes=50MB
stopwaitsecs=600
stopsignal=TERM
; /etc/supervisor/conf.d/celery-emails.conf
[program:celery-emails]
command=/var/www/app/venv/bin/celery -A config worker -l warning -Q emails -c 2
directory=/var/www/app/current
user=www-data
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/supervisor/celery-emails.log
stopwaitsecs=300
stopsignal=TERM
; /etc/supervisor/conf.d/celery-beat.conf
[program:celery-beat]
command=/var/www/app/venv/bin/celery -A config beat -l warning --scheduler django_celery_beat.schedulers:DatabaseScheduler
directory=/var/www/app/current
user=www-data
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/supervisor/celery-beat.log
numprocs=1
Important: Celery Beat must run with `numprocs=1`. Running multiple Beat instances causes duplicate task execution. The `DatabaseScheduler` stores the schedule in PostgreSQL, allowing runtime schedule changes through the Django admin.
Deployment Script
#!/bin/bash
<h1>deploy.sh - Zero-downtime Django deployment</h1>
set -euo pipefail
APP_DIR="/var/www/app"
RELEASES_DIR="$APP_DIR/releases"
SHARED_DIR="$APP_DIR/shared"
RELEASE=$(date +%Y%m%d%H%M%S)
NEW_RELEASE="$RELEASES_DIR/$RELEASE"
echo "==> Cloning release $RELEASE"
git clone --depth 1 [email protected]:your-org/your-app.git "$NEW_RELEASE"
echo "==> Installing Python dependencies"
cd "$NEW_RELEASE"
python3 -m venv venv
source venv/bin/activate
pip install --no-cache-dir -r requirements/production.txt
echo "==> Linking shared resources"
ln -nfs "$SHARED_DIR/.env" "$NEW_RELEASE/.env"
ln -nfs "$SHARED_DIR/media" "$NEW_RELEASE/media"
echo "==> Running migrations"
python manage.py migrate --noinput
echo "==> Collecting static files"
python manage.py collectstatic --noinput
echo "==> Switching symlink"
ln -nfs "$NEW_RELEASE" "$APP_DIR/current"
echo "==> Restarting services"
sudo supervisorctl restart gunicorn
sudo supervisorctl restart celery-default celery-emails
echo "==> Cleaning old releases (keeping last 5)"
cd "$RELEASES_DIR"
ls -dt */
tail -n +6
xargs rm -rf
echo "==> Deployment complete: $RELEASE"
For managed deployment workflows with built-in rollback, see the Services Overview page. Arxentra provides one-click Django deployment with all of these configurations pre-applied.
Performance Benchmarks
| Configuration | Requests/sec | P95 Latency | Memory Usage |
| Default Django runserver | 50 | 850ms | 200MB |
| Gunicorn (sync, 9 workers) | 480 | 120ms | 900MB |
| Gunicorn (gthread, 9x4) | 780 | 85ms | 750MB |
| Gunicorn + Redis cache | 1,100 | 45ms | 800MB |
| Full stack (nginx + cache + CDN) | 2,500+ | 25ms | 850MB |
The jump from runserver to a full production stack is 50x in throughput and 34x in latency improvement.
Read more about Cloud Computing infrastructure options for Django at scale.
Frequently Asked Questions
Q: How many Gunicorn workers should I use?
A: Start with `(2 x CPU cores) + 1`. For a 4 vCPU server, that is 9 workers. If your application is I/O-bound (many database queries, external API calls), switch to `gthread` worker class with 2-4 threads per worker to handle 36+ concurrent requests.
Q: Should I use Gunicorn or uWSGI?
A: Gunicorn for most Django applications. It is simpler to configure, has better documentation, and integrates cleanly with Django. uWSGI offers more features (Emperor mode, caching, routing) but the complexity is rarely justified. Both deliver similar throughput.
Q: How do I handle Django migrations on a multi-server deployment?
A: Run migrations on exactly one server before switching the symlink. Use the `--run-syncdb` flag for initial deployments. For zero-downtime migrations, ensure migrations are backward-compatible: add new columns as nullable, create new tables before referencing them, never rename columns in a single migration.
Q: What is the best way to serve Django static files?
A: In order of recommendation: (1) CDN with WhiteNoise origin, (2) nginx serving static files directly, (3) WhiteNoise alone. Option 1 gives the best performance globally. Option 2 is simplest for single-server deployments. Option 3 works anywhere without nginx configuration changes.
Q: How do I debug performance issues in production Django?
A: Install django-silk or django-debug-toolbar (restricted to admin IPs). Monitor Gunicorn access logs for slow requests (the `%(D)s` format shows request duration in microseconds). Use `EXPLAIN ANALYZE` on slow database queries. Check Celery task durations in the result backend.
Q: Can I run Django with ASGI instead of WSGI?
A: Yes, use `uvicorn` or `daphne` as the ASGI server with `gunicorn -k uvicorn.workers.UvicornWorker`. ASGI is required for Django Channels (WebSockets) and provides better performance for long-polling or streaming responses. For standard request-response patterns, WSGI and ASGI perform similarly. Check the Documentation for ASGI setup guides.