Django on VPS: Gunicorn, Nginx, Celery, and Supervisor Production Setup

Published 22 min read
djangopythonvps hostinggunicorncelerynginx

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

ConfigurationRequests/secP95 LatencyMemory Usage

Default Django runserver50850ms200MB
Gunicorn (sync, 9 workers)480120ms900MB
Gunicorn (gthread, 9x4)78085ms750MB
Gunicorn + Redis cache1,10045ms800MB
Full stack (nginx + cache + CDN)2,500+25ms850MB

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.