Deploy Django with uWSGI and Nginx
Run Django with uWSGI and Nginx: a strict uWSGI ini, socket permissions, systemd integration, graceful reloads, harakiri timeouts, and checks.
Problem statement
uWSGI is a mature, very configurable application server that many existing Django deployments still run. You may be inheriting one, or you may need features such as its native uwsgi protocol with Nginx, per-request harakiri timeouts, or fine-grained process management.
uWSGI's flexibility is also its main risk. It has hundreds of options, several defaults that surprise Django users, and configuration mistakes that fail silently:
- a typo in an option name is ignored unless strict mode is on
- the app fails to import but uWSGI keeps running and returns errors for every request
SIGTERMtriggers a reload instead of shutdown unless configured otherwise- the socket is created with permissions Nginx cannot use
For new projects, Gunicorn is simpler, and the uWSGI project itself has been in maintenance mode for several years. This guide is for teams that choose or maintain uWSGI and want a safe, explicit configuration.
Quick answer
- Install uWSGI into the project virtualenv (
pip install uwsgi), not from a distribution package with plugins you do not use. - Write an ini file with
strict,master,need-app,die-on-term,vacuum, a Unixsocketwithchmod-socket = 660, and aharakiritimeout. - Run it as a systemd service with
Type=notifyandKillSignal=SIGQUIT, and reload gracefully withExecReload=/bin/kill -HUP $MAINPID. - Configure Nginx with
include uwsgi_params;anduwsgi_pass unix:/run/myapp/uwsgi.sock;. - Serve static files from Nginx.
- Verify the socket, the logs, and a request through Nginx.
Step-by-step solution
1. Install uWSGI in the virtualenv
uWSGI compiles a C extension, so install a compiler and Python headers first:
sudo apt install build-essential python3-dev
/srv/myapp/venv/bin/pip install uwsgi
/srv/myapp/venv/bin/uwsgi --version
Installing from pip ties uWSGI to the virtualenv's Python. Distribution packages use a plugin model where the Python plugin must match the interpreter, a common source of "no app loaded" errors.
2. Write the uWSGI configuration
/etc/myapp/uwsgi.ini:
[uwsgi]
strict = true
master = true
need-app = true
enable-threads = true
single-interpreter = true
die-on-term = true
vacuum = true
chdir = /srv/myapp/app
home = /srv/myapp/venv
module = config.wsgi:application
env = DJANGO_SETTINGS_MODULE=config.settings
uid = myapp
gid = www-data
socket = /run/myapp/uwsgi.sock
chmod-socket = 660
processes = 4
threads = 2
harakiri = 60
max-requests = 5000
max-requests-delta = 500
reload-on-rss = 512
disable-logging = false
log-4xx = true
log-5xx = true
What the important options do:
strict = truemakes uWSGI refuse to start when the file contains an unknown option, so typos do not silently disable settings.need-app = trueexits if the WSGI module fails to import, instead of running a server that returns errors for every request.die-on-term = truemakesSIGTERMshut uWSGI down. Without it, older versions treatSIGTERMas a reload request.vacuum = trueremoves the socket and pid files on exit.single-interpreter = truedisables multiple interpreter support, which Django does not use.harakiri = 60kills a worker stuck on one request for more than 60 seconds and logs the request.max-requestswithmax-requests-deltarecycles workers periodically at staggered counts to limit memory growth.reload-on-rss(in megabytes) restarts a worker after a request leaves it above that resident memory size.
Secrets should not be written into this ini file. Load them from an environment file in systemd (step 3).
3. Run it with systemd
/etc/systemd/system/myapp.service:
[Unit]
Description=myapp uWSGI
After=network.target
[Service]
Type=notify
EnvironmentFile=/etc/myapp/env
RuntimeDirectory=myapp
RuntimeDirectoryMode=0755
ExecStart=/srv/myapp/venv/bin/uwsgi --ini /etc/myapp/uwsgi.ini
ExecReload=/bin/kill -HUP $MAINPID
KillSignal=SIGQUIT
NotifyAccess=all
Restart=on-failure
[Install]
WantedBy=multi-user.target
- uWSGI supports systemd's notify protocol, so
Type=notifymarks the service as started only when uWSGI reports it is ready. SIGHUPperforms a graceful reload: workers finish current requests, and the app is reloaded.SIGQUITshuts uWSGI down quickly, which is what systemd should send on stop.RuntimeDirectory=myappcreates/run/myappat start. Because uWSGI starts as root and drops privileges withuid/gid, the directory can stay owned by root, and uWSGI creates the socket before dropping privileges.
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
journalctl -u myapp -n 50 --no-pager
4. Configure Nginx
upstream myapp {
server unix:/run/myapp/uwsgi.sock;
}
server {
listen 80;
server_name www.example.com;
client_max_body_size 20m;
location /static/ {
alias /srv/myapp/app/staticfiles/;
expires 30d;
}
location /media/ {
alias /srv/myapp/media/;
}
location / {
include uwsgi_params;
uwsgi_pass myapp;
uwsgi_read_timeout 65s;
uwsgi_param HTTP_X_FORWARDED_PROTO $scheme;
}
}
The uwsgi_params file shipped with Nginx maps request data such as REQUEST_METHOD, QUERY_STRING, and HTTP_HOST into the uwsgi protocol. Keep uwsgi_read_timeout slightly above harakiri, so uWSGI's timeout fires first and logs the slow request.
The uwsgi_param HTTP_X_FORWARDED_PROTO $scheme line passes the original scheme to Django, where it appears in request.META as HTTP_X_FORWARDED_PROTO. After enabling HTTPS, set:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
Test and reload:
sudo nginx -t && sudo systemctl reload nginx
5. Socket permissions
The socket is owned by myapp:www-data with mode 660. Nginx on Debian and Ubuntu runs its workers as www-data, so it can connect through the group permission. Check:
ls -l /run/myapp/uwsgi.sock
sudo -u www-data test -w /run/myapp/uwsgi.sock && echo "nginx can write"
If Nginx logs connect() to unix:/run/myapp/uwsgi.sock failed (13: Permission denied), the group or mode is wrong, or a parent directory is not traversable.
6. Deploy new code
A release on this stack looks like:
cd /srv/myapp/app
sudo -u myapp git pull
sudo -u myapp /srv/myapp/venv/bin/pip install -r requirements.txt
sudo -u myapp bash -c 'set -a; . /etc/myapp/env; set +a; \
/srv/myapp/venv/bin/python manage.py migrate --noinput && \
/srv/myapp/venv/bin/python manage.py collectstatic --noinput'
sudo systemctl reload myapp
The reload replaces workers gracefully. If you changed the uWSGI ini itself (for example the number of processes), use systemctl restart.
7. Verify
curl -I http://127.0.0.1/ -H "Host: www.example.com"
journalctl -u myapp -f
sudo tail -f /var/log/nginx/error.log
Verification check:
- the journal shows
spawned uWSGI master processand the configured number of workers - there is no
no python application foundmessage - a request through Nginx returns the expected status
systemctl reload myappcompletes without dropped requests in the Nginx error log
Rollback
Keep releases in versioned directories and point a current symlink at the active one, with chdir = /srv/myapp/current in the ini. Rolling back is switching the symlink to the previous release and running systemctl restart myapp, because a graceful reload may keep resolved paths from the old symlink target. Database migrations are not reverted automatically.
Explanation
Both Gunicorn and uWSGI run a master process that manages worker processes importing your Django WSGI application. uWSGI adds its own binary protocol, which Nginx speaks natively with uwsgi_pass, and many runtime controls. The ini above turns on the options that make failures visible (strict, need-app), makes process signals behave the way systemd expects (die-on-term, KillSignal=SIGQUIT), and bounds the damage of slow or leaking requests (harakiri, max-requests, reload-on-rss).
Edge cases / notes
uWSGI or Gunicorn for a new project?
Choose Gunicorn unless you need a specific uWSGI feature. It has fewer options, clear defaults, and active development. The Nginx and systemd patterns transfer almost unchanged.
Threads and connection handling
With threads = 2, each worker can serve two requests at once. Django's database connections are per thread, so the maximum database connections is roughly processes × threads.
Background threads in the app
enable-threads = true is required if your code or libraries start Python threads (for example some SDKs that flush telemetry in the background); without it, those threads do not run.
lazy-apps
By default uWSGI imports the app in the master and forks workers. If a library opens connections or sockets at import time, use lazy-apps = true so each worker imports the app itself.
Troubleshooting uWSGI
Reading the startup log
A healthy start in journalctl -u myapp includes, in order:
*** Starting uWSGI ...with the versionchdir() to /srv/myapp/appuwsgi socket 0 bound to UNIX address /run/myapp/uwsgi.sockWSGI app 0 (mountpoint='') readyspawned uWSGI master processand onespawned uWSGI workerline per process
If step 4 is missing and you see unable to load app 0 or no python application found, the module import failed. Scroll up for the Python traceback. With need-app = true, uWSGI then exits and systemd restarts it according to Restart=on-failure.
Common error messages
| Message | Cause | Fix |
|---|---|---|
unrecognized option or strict mode exit | typo or unsupported option name | correct the option; check uwsgi --help for the installed version |
bind(): Permission denied | socket directory not writable | RuntimeDirectory= in the unit, or correct ownership |
HARAKIRI ON WORKER | a request exceeded harakiri seconds | find the slow view in the logged request line |
invalid request block size | client sent HTTP to a socket (uwsgi protocol) port | use uwsgi_pass in Nginx, or http-socket for HTTP |
!!! uWSGI process ... got Segmentation Fault !!! | crash in a C extension | try lazy-apps = true; check library compatibility with forking |
The invalid request block size error is common when testing with curl directly against the uWSGI socket or port: socket speaks the binary uwsgi protocol, not HTTP. Test through Nginx instead, or temporarily add an http-socket = 127.0.0.1:8001 line for local HTTP testing.
Monitoring worker state
uWSGI can expose a stats server with per-worker request counts, memory, and status:
stats = /run/myapp/stats.sock
memory-report = true
Read it with uwsgitop (installed with pip install uwsgitop):
sudo -u myapp /srv/myapp/venv/bin/uwsgitop /run/myapp/stats.sock
Keep the stats socket on a Unix path or localhost only; it reveals internal details.
Migrating from uWSGI to Gunicorn
If you decide to move, the change is contained:
- Install Gunicorn in the same virtualenv.
- Replace the systemd
ExecStartwith a Gunicorn command binding to a Unix socket. - Replace
include uwsgi_params; uwsgi_pass ...;in Nginx withproxy_pass http://unix:/run/myapp/gunicorn.sock;and the standardproxy_set_headerlines. - Map
harakirito Gunicorn's--timeout,processesto--workers,threadsto--threads, andmax-requeststo--max-requestswith--max-requests-jitter.
Deploy the switch during a low-traffic window, because the socket path and protocol change at the same time.
Next steps
- Compare with the more common stack in Deploy Django with Gunicorn and Nginx on Ubuntu.
- Tune the proxy in How to Configure Nginx for Django in Production.
- When requests fail at the proxy, use Django 502 Bad Gateway: Causes and Fixes.
FAQ
What does "no python application found" mean in uWSGI?
uWSGI started but could not import your WSGI module. Check chdir, module, home, and the traceback above the message. With need-app = true, uWSGI exits instead of continuing without an app.
How do I reload Django code with uWSGI without downtime?
Send SIGHUP to the master process, for example with systemctl reload using ExecReload=/bin/kill -HUP $MAINPID. Workers finish in-flight requests and are replaced with the new code.
Should Nginx talk to uWSGI over HTTP or the uwsgi protocol?
Use the uwsgi protocol with uwsgi_pass and a Unix socket when Nginx and uWSGI are on the same host. It is efficient and avoids exposing an HTTP port.
What is harakiri in uWSGI?
A per-request timeout. If a worker spends longer than the harakiri value on one request, the master kills and replaces it and logs the offending request, which is useful for finding slow endpoints.