A production Django deployment uses three cooperating layers: Nginx as the public-facing reverse proxy and static-file server, uWSGI as the application server that runs your Python code, and Django itself as the WSGI application. Nginx accepts every request on ports 80/443, serves /static/ and /media/ straight from disk, terminates TLS, and forwards only dynamic requests to uWSGI over a fast Unix socket. uWSGI keeps a pool of worker processes warm and speaks the binary uwsgi protocol back to Nginx.
You never use python manage.py runserver in production. The development server is single-threaded, unoptimised, and explicitly not security-hardened (the Django docs say exactly that). This guide gives you the full, current setup on Ubuntu 24.04 with Python 3.11+ and Django 5.x.
Heads-up for 2026: uWSGI is now in maintenance mode. The maintainers announced in late 2022 that the project would receive only maintenance and security fixes, with no new features. It is still rock-solid and very widely deployed, but for brand-new synchronous projects most teams now reach for Gunicorn, and for async Django you need an ASGI server. The comparison below explains the trade-offs; this guide then configures the full uWSGI path end to end as the title promises.
We have built, deployed, and maintained Django on Linux for 12+ years across 50+ projects, so the configuration here is close to what we actually run in production. If you want a deeper hand, see our Django development services and Python engineering.
How a request actually flows
Browser --HTTP/HTTPS--> Nginx --uwsgi proto (Unix socket)--> uWSGI --WSGI--> Django
^ |
+-- /static/, /media/ -+ served directly from disk, never touches Python
Why a separate application server? Django is a WSGI application, not a network server. uWSGI (or Gunicorn) loads your code once, manages a pool of worker processes, and turns incoming requests into Python calls. It handles process supervision, worker recycling, and concurrency so your app does not have to.
Why a reverse proxy in front? An application server should not face the raw internet. Nginx terminates TLS, serves static and media files far faster than Python, buffers slow clients so a worker is never tied up waiting on a slow connection, gzips responses, and gives you a single place for caching, rate limiting, and security headers. Splitting the two jobs is the standard, resilient production topology.
uWSGI vs Gunicorn vs ASGI in 2026
Pick the application server before you write a line of config. All three sit in the same slot behind Nginx.
| Application server | Protocol | Best for | 2026 status |
|---|---|---|---|
| uWSGI | WSGI (sync) | Battle-tested and feature-rich: Emperor, built-in caching, cron, fine-grained tuning | Maintenance mode (security and fixes only) |
| Gunicorn | WSGI (sync) | Simplest config; the de-facto default for Django today | Actively developed; recommended for new sync apps |
| Uvicorn / Daphne / Hypercorn | ASGI (async) | Async views, Django Channels, WebSockets, SSE, streaming | Actively developed; required for async |
Our recommendation: for a new synchronous project, start with Gunicorn. For anything async (Channels, WebSockets, long-lived connections), use an ASGI server. Choose uWSGI when you want its richer feature set or you are maintaining an existing uWSGI deployment, which is exactly what the rest of this guide sets up, soup to nuts.
Server prep: virtualenv, Django, and uWSGI
Create a dedicated django user, an isolated virtual environment, and install Django plus uWSGI into it. Note that pip install uwsgi compiles a small C core, so the box needs python3-dev and build-essential.
# Ubuntu 24.04 -- system packages
sudo apt update
sudo apt install -y python3 python3-venv python3-dev build-essential nginx
# dedicated service user + project directory
sudo adduser --system --group --home /home/django django
sudo mkdir -p /home/django/myproject && sudo chown -R django:django /home/django
cd /home/django/myproject
# isolated virtual environment
python3 -m venv .venv
source .venv/bin/activate
# install Django 5.x and uWSGI INTO the venv (not globally)
pip install --upgrade pip
pip install 'Django>=5.0,<6.0' uwsgi
# start a project if you do not have one yet (note the trailing dot)
django-admin startproject myproject .Test uWSGI directly before wiring up Nginx
Confirm uWSGI can import and serve your WSGI app on its own. First over HTTP (a quick smoke test), then over the Unix socket Nginx will actually use.
# run from the directory that contains manage.py and the myproject/ package
# 1) HTTP mode -- open http://SERVER_IP:8000 and you should see your site
uwsgi --http :8000 --module myproject.wsgi
# stop with Ctrl+C, then 2) socket mode (what production uses):
uwsgi --socket /run/uwsgi/myproject.sock \
--module myproject.wsgi \
--virtualenv /home/django/myproject/.venv \
--chmod-socket=660The uWSGI .ini config file
Hand-typing flags gets old fast. Move everything into an annotated .ini next to manage.py. This file is the heart of the setup.
; /home/django/myproject/myproject_uwsgi.ini
[uwsgi]
; --- paths ---
chdir = /home/django/myproject
module = myproject.wsgi:application
home = /home/django/myproject/.venv ; the virtualenv
env = DJANGO_SETTINGS_MODULE=myproject.settings
; --- process management ---
master = true
processes = 4 ; rule of thumb: ~2 x CPU cores
threads = 2
; --- the socket Nginx talks to ---
socket = /run/uwsgi/myproject.sock
chmod-socket = 660 ; rw for owner + group
chown-socket = django:www-data ; group must be Nginx's user
; --- hygiene / resilience ---
vacuum = true ; remove the socket file on exit
die-on-term = true ; honour systemd SIGTERM cleanly
harakiri = 30 ; kill any worker stuck longer than 30s
max-requests = 5000 ; recycle workers to curb memory leaks
buffer-size = 8192 ; allow larger request headers# validate the ini by running it directly before adding systemd
/home/django/myproject/.venv/bin/uwsgi --ini /home/django/myproject/myproject_uwsgi.iniRun uWSGI under systemd
Modern Ubuntu uses systemd, not upstart or init.d, and you should let it supervise uWSGI: start on boot, restart on crash, and capture logs in the journal. This replaces older patterns such as supervisor -- though if you still prefer that approach, see our note on daemonizing any command with supervisor.
RuntimeDirectory=uwsgi makes systemd create and own /run/uwsgi (with the right permissions) on every start, which is where the socket lives.
# /etc/systemd/system/myproject.service
[Unit]
Description=uWSGI app server for myproject (Django)
After=network.target
[Service]
User=django
Group=www-data
RuntimeDirectory=uwsgi
RuntimeDirectoryMode=0775
WorkingDirectory=/home/django/myproject
ExecStart=/home/django/myproject/.venv/bin/uwsgi --ini /home/django/myproject/myproject_uwsgi.ini
ExecReload=/bin/kill -HUP $MAINPID
KillSignal=SIGQUIT
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now myproject.service
sudo systemctl status myproject.service
# follow the logs (replaces hunting through scattered log files):
sudo journalctl -u myproject -fAlternative: uWSGI Emperor
If you run several Django apps on one box, the Emperor lets a single process supervise many vassal .ini files. Symlink each app's config into a vassals directory and the Emperor starts, stops, and reloads them automatically when those files change.
# one Emperor supervising every app under /etc/uwsgi/vassals
# (ExecStart for an /etc/systemd/system/uwsgi-emperor.service unit)
/usr/local/bin/uwsgi --emperor /etc/uwsgi/vassals --uid django --gid www-data
# then symlink each app's ini into the vassals dir:
sudo ln -s /home/django/myproject/myproject_uwsgi.ini /etc/uwsgi/vassals/The Nginx server block
Nginx forwards dynamic requests to the uWSGI socket via uwsgi_pass and include uwsgi_params, and serves /static/ and /media/ straight from disk. Set client_max_body_size to match your largest upload, and turn on gzip.
# /etc/nginx/sites-available/myproject
upstream myproject_uwsgi {
server unix:/run/uwsgi/myproject.sock;
}
server {
listen 80;
server_name example.com www.example.com;
charset utf-8;
client_max_body_size 75M; # cap upload size
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# static + media: served by Nginx, never reaching Django
location /static/ {
alias /home/django/myproject/staticfiles/;
expires 30d;
access_log off;
}
location /media/ {
alias /home/django/myproject/media/;
}
# everything else -> uWSGI -> Django
location / {
uwsgi_pass myproject_uwsgi;
include uwsgi_params; # ships with nginx
uwsgi_param HTTP_X_FORWARDED_PROTO $scheme; # so Django sees https
}
}# collect static assets into STATIC_ROOT so Nginx can serve them
source /home/django/myproject/.venv/bin/activate
python manage.py collectstatic --noinput
# enable the site, drop the default, test config, and reload
sudo ln -s /etc/nginx/sites-available/myproject /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxHTTPS with Let's Encrypt and certbot
Terminate TLS at Nginx. Forget manual certificate juggling: certbot obtains a free Let's Encrypt certificate, rewrites your vhost for HTTPS, adds the HTTP-to-HTTPS redirect, and installs a renewal timer.
# install certbot via snap (the maintainers' recommended channel)
sudo snap install --classic certbot
sudo ln -sf /snap/bin/certbot /usr/bin/certbot
# obtain + install the cert and auto-edit the nginx vhost (adds the 80->443 redirect)
sudo certbot --nginx -d example.com -d www.example.com
# certbot installs a systemd timer for renewal; confirm it works:
sudo certbot renew --dry-runDjango production settings
Finally, harden settings.py. The STATIC_ROOT and MEDIA_ROOT paths must match the Nginx alias directories, and the security flags assume Nginx is terminating TLS in front of you.
# myproject/settings.py (production)
import os
DEBUG = False
ALLOWED_HOSTS = ['example.com', 'www.example.com']
CSRF_TRUSTED_ORIGINS = ['https://example.com', 'https://www.example.com']
SECRET_KEY = os.environ['DJANGO_SECRET_KEY'] # never hard-code secrets
# static / media -- must match the Nginx alias paths above
STATIC_URL = '/static/'
STATIC_ROOT = '/home/django/myproject/staticfiles'
MEDIA_URL = '/media/'
MEDIA_ROOT = '/home/django/myproject/media'
# Nginx terminates TLS, so trust the scheme it forwards
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# HSTS -- start modest, raise to 31536000 (one year) once you are confident
SECURE_HSTS_SECONDS = 2592000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_CONTENT_TYPE_NOSNIFF = TrueTroubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| 502 Bad Gateway | uWSGI is down, or Nginx cannot read the socket | systemctl status myproject; make the .ini socket= path match uwsgi_pass; ensure the socket group is www-data |
| Static files 404 / unstyled admin | collectstatic not run, wrong STATIC_ROOT, or alias mismatch |
run collectstatic; make the location /static/ alias equal STATIC_ROOT; then nginx -t && reload |
| Permission denied on socket | Nginx (www-data) lacks access to the .sock |
set chmod-socket = 660 plus chown-socket = django:www-data; let RuntimeDirectory own /run/uwsgi |
| 504 / worker timeout | request runs longer than harakiri |
raise harakiri; bump Nginx uwsgi_read_timeout; push slow work to Celery |
| 500 with no detail | app error while DEBUG=False |
read journalctl -u myproject; verify ALLOWED_HOSTS and DJANGO_SECRET_KEY |
Going further
Once the single box is solid, the usual next steps are a managed Postgres instance, Redis for cache and Celery, centralised logging, and horizontal scaling behind a load balancer. If you are moving this onto the cloud, our AWS consulting and cloud migration teams handle the VPC, autoscaling, and zero-downtime cut-over.
At MicroPyramid we have deployed and operated Django on Linux for 12+ years across 50+ projects, with Nginx, uWSGI, Gunicorn, ASGI, systemd, and the monitoring around them. If you would rather hand it off, our Django and Python engineering teams can take it from here.
Frequently Asked Questions
uWSGI vs Gunicorn -- which should I use in 2026?
For a brand-new synchronous Django project, start with Gunicorn: it is actively maintained, simpler to configure, and the de-facto default in the Django community. Choose uWSGI when you want its richer feature set (the Emperor, built-in caching, cron, fine-grained tuning) or you are maintaining an existing uWSGI deployment; it is in maintenance mode but still stable and secure. If your app uses async views, Channels, or WebSockets, neither WSGI server fits and you need an ASGI server such as Uvicorn or Daphne.
Why am I getting a 502 Bad Gateway?
A 502 means Nginx reached the right location block but could not talk to uWSGI. The three usual causes are: uWSGI is not running (check systemctl status myproject), the socket path in your .ini does not match uwsgi_pass in Nginx, or Nginx (running as www-data) cannot read the socket file. Set chmod-socket = 660 and chown-socket = django:www-data, then restart both services.
Why are my static files not loading?
In production Django does not serve static files; Nginx does. Run python manage.py collectstatic so every asset lands in STATIC_ROOT, then make sure your Nginx location /static/ alias points at that exact directory. An unstyled admin page is the classic sign that collectstatic was skipped or the alias path is wrong.
Do I really need both Nginx and uWSGI?
For production, yes. uWSGI (or Gunicorn) runs your Python code but is not built to face the public internet directly: it should not terminate TLS, serve static files, or absorb slow-client and abusive traffic. Nginx sits in front to handle TLS, static and media, compression, buffering, and rate limiting, and forwards only dynamic requests to the app server. Running uWSGI alone is fine for a quick test, not for real traffic.
uWSGI socket mode vs HTTP mode -- what's the difference?
HTTP mode (--http :8000) makes uWSGI speak HTTP directly, which is handy for a quick local smoke test. In production you use a Unix socket and let Nginx talk to it with the binary uwsgi protocol via uwsgi_pass: it is faster, avoids an extra TCP hop, and can be locked down with filesystem permissions. Use a TCP socket such as 127.0.0.1:8001 only when Nginx and uWSGI run on different hosts.
How do I serve async Django or WebSockets?
WSGI servers like uWSGI and Gunicorn cannot handle long-lived async connections. For async views, Django Channels, WebSockets, or server-sent events you need an ASGI server (Uvicorn, Daphne, or Hypercorn) pointed at your project's asgi.py instead of wsgi.py. Nginx still sits in front as the reverse proxy, but you add the WebSocket upgrade headers (Upgrade and Connection) to the proxied location.