Run Gunicorn Behind Nginx for a WSGI Python App
Put Gunicorn behind nginx so a WSGI Python app serves TLS, buffering, and workers without exposing the application server to the public internet.
Netbay Developer Relations
Netbay Engineering
On this page
Gunicorn is a WSGI HTTP server. It is not a TLS terminator, not a static file server, and not a public edge. Bind it to 127.0.0.1 and put nginx in front. That split is the entire production story for Flask, Django, and any other WSGI app on a single Ubuntu VPS.
The pattern is old because it works. nginx accepts 443, holds slow clients, serves robots.txt, and proxies to Gunicorn over a local socket or TCP port. Gunicorn owns Python workers. Mixing those jobs in one process is how you get TLS outages when you reload Python, or blocked workers when a client dribbles a request body.
This setup assumes the boring venv from the previous post: user app, directory /srv/app, interpreter /srv/app/.venv/bin/python. The app is a WSGI callable named app in wsgi.py.
Bind Gunicorn to Localhost, Not 0.0.0.0
A public Gunicorn is a public debug server with extra workers. Always bind loopback or a Unix socket.
sudo -u app -H /srv/app/.venv/bin/pip install "gunicorn==22.0.0"
sudo -u app -H /srv/app/.venv/bin/gunicorn \
--bind 127.0.0.1:8000 \
--workers 3 \
--timeout 30 \
--access-logfile - \
--error-logfile - \
wsgi:appworkers is not a throughput magic number. A starting point is 2 * CPU + 1 for sync workers. On a 2 vCPU Intel Xeon Platinum VPS that is 5, which is often too many if each worker also holds a database connection. Start with 3, watch RAM, then raise. The sync worker class is fine until you have slow I/O; gevent or gthread is a later change, not a day-one requirement.
timeout 30 kills a worker that never returns. Without it, one stuck ORM query occupies a worker forever and the box looks "up" while every fourth request hangs. access logs to stdout so systemd journald captures them. Do not invent a second log rotation stack on day one.
Unix sockets are slightly faster and keep the TCP port table clean:
# /etc/nginx/sites-available/app
server {
listen 80;
server_name app.example.com;
client_max_body_size 20m;
location /static/ {
alias /srv/app/static/;
access_log off;
}
location / {
proxy_pass http://127.0.0.1:8000;
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 60s;
}
}Host, X-Real-IP, and X-Forwarded-Proto are not decoration. Django and Flask build absolute URLs from them. Miss X-Forwarded-Proto and password-reset links stay on http. Miss Host and you generate links on 127.0.0.1. Enable the forwarded proto only from nginx; Gunicorn should not trust those headers from the public internet because the public never talks to Gunicorn.
Why Nginx Stays in Front
Gunicorn's HTTP parser is good enough for application traffic, not for the hostile internet. nginx gives you TLS, request buffering, static files, and a place to drop junk before it occupies a Python worker. L3/L4 DDoS filtering at the VPS edge handles volumetric noise; nginx still needs to reject slow HTTP tricks and oversized bodies.
- TLS lives in nginx, renewed by certbot or a similar client, never inside Python.
- client_max_body_size stops a 2 GB upload from filling High-Speed SSD disk through a forgotten form.
- proxy_read_timeout should be a bit higher than Gunicorn's timeout so the proxy does not cut a request the worker is still finishing.
- Buffering means a slow mobile client does not hold a Gunicorn worker open while it dribbles the request.
Reload nginx with nginx -t && systemctl reload nginx. Reload Gunicorn with a systemd restart or a HUP, not by killing -9 the master.
Worker Math and Failure Modes
Three failure modes show up in the first month.
First, worker count times connection pool size exceeds Postgres max_connections. Three Gunicorn workers each opening 10 SQLAlchemy connections is 30 sessions before you add a queue. Cap the pool per process.
Second, memory. Sync workers load the whole app. A Django RSS with Pillow can be 150 MB each. Five workers is 750 MB plus the OS, plus postgres. On 2 GB RAM that is already tight. Watch RSS with ps and do not copy a 2 * CPU + 1 blog post blindly.
Third, graceful restart. Gunicorn's master handles HUP by starting new workers and retiring old ones. If you change code that is imported at fork time, restart after git pull. If you only change templates loaded per request, a HUP is enough. Know which kind of change you just shipped.
Health Checks and Bind Address
Point nginx at a cheap health path that does not hit the database, then add a second check that does. A /healthz that returns 200 from the WSGI app tells you Gunicorn is up. A /readyz that SELECT 1 tells you the app can still reach Postgres. Do not make the load balancer, if you add one later, depend on a check that takes a lock.
Confirm the bind:
- ss -lptn | grep 8000 must show 127.0.0.1, never 0.0.0.0.
- curl -I http://127.0.0.1:8000/healthz from the box should be 200.
- curl -I http://PUBLIC_IP:8000/ from another machine must fail.
If port 8000 is public, your firewall is wrong. ufw should allow 22, 80, and 443 only. Lucknow-hosted VPS instances on Netbay arrive with a public IPv4; treat that address as hostile until nginx is the only listener.
Takeaway
Gunicorn runs the WSGI callable. nginx owns TLS, buffering, and static files. Bind 127.0.0.1, set a worker timeout, and size workers against RAM and database connections rather than a CPU formula. That is the whole split.
You can spin up an Ubuntu 24.04 instance on Netbay in under 60 seconds and put this reverse proxy in place — netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance