Deploy a Python App on a VPS: Gunicorn, Nginx & systemd
If you've deployed Python apps on shared hosting before, you're used to a control panel doing the heavy lifting. On a VPS, there's no Passenger and no "Setup Python App" button - you're wiring together the app server, the process manager, and the reverse proxy yourself. Here's how to get a Django or Flask app running behind Gunicorn and Nginx, managed by systemd so it survives a reboot instead of dying the moment your SSH session closes.
Why you need three separate pieces
A common first mistake is running python manage.py runserver or flask run on a VPS and pointing a domain straight at it. That built-in dev server is single-threaded, has no production hardening, and stops the second your terminal disconnects. You need:
- Gunicorn - a WSGI server that actually runs your Python code, with multiple worker processes to handle concurrent requests.
- systemd - keeps Gunicorn running in the background, restarts it if it crashes, and starts it automatically on boot.
- Nginx - sits in front of Gunicorn, terminates SSL, serves static files directly (much faster than routing them through Python), and can proxy multiple apps on one server.
Step 1: Set up the project and a virtual environment
SSH into your VPS and create a dedicated system user for the app instead of running everything as root - if the app gets compromised, you don't want it owning the whole server:
sudo adduser --system --group deploy
sudo mkdir -p /var/www/myapp
sudo chown deploy:deploy /var/www/myapp
sudo -u deploy -i
cd /var/www/myapp
git clone https://github.com/yourname/myapp.git .
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt gunicorn
For Django, also run python manage.py collectstatic and python manage.py migrate now, while you're still in the venv and can see errors directly instead of buried in a systemd log.
Step 2: Test Gunicorn on its own first
Before wiring up systemd, confirm Gunicorn can actually serve the app. For Django:
gunicorn --bind 127.0.0.1:8000 myproject.wsgi:application
For Flask, it's usually gunicorn --bind 127.0.0.1:8000 app:app (adjust app:app to match your module and Flask instance name). Hit curl 127.0.0.1:8000 from another terminal on the same server. If that returns your app's HTML, move on. If it errors out, fix it here - debugging inside systemd later is slower because you're reading logs instead of watching output live.
Step 3: Create the systemd service
This is what keeps Gunicorn alive long-term. Create /etc/systemd/system/myapp.service:
[Unit]
Description=Gunicorn instance for myapp
After=network.target
[Service]
User=deploy
Group=www-data
WorkingDirectory=/var/www/myapp
Environment="PATH=/var/www/myapp/venv/bin"
ExecStart=/var/www/myapp/venv/bin/gunicorn \
--workers 3 \
--bind unix:/var/www/myapp/myapp.sock \
myproject.wsgi:application
Restart=on-failure
[Install]
WantedBy=multi-user.target
Binding to a Unix socket instead of a TCP port is slightly faster and keeps Gunicorn from being reachable directly from outside - only Nginx, running on the same box, can talk to it. A rough starting point for worker count is (2 x CPU cores) + 1; you can tune this later once you see real traffic and memory usage.
Enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
Step 4: Point Nginx at the socket
Create /etc/nginx/sites-available/myapp:
server {
listen 80;
server_name example.com www.example.com;
location /static/ {
alias /var/www/myapp/staticfiles/;
}
location / {
include proxy_params;
proxy_pass http://unix:/var/www/myapp/myapp.sock;
}
}
Symlink it and reload Nginx:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx -t checks the config for syntax errors before you reload - always run it first, since a bad config will otherwise take your whole Nginx down, not just the new site.
Step 5: Firewall and SSL
Lock down everything except SSH, HTTP, and HTTPS:
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
Then get a free certificate and let Certbot rewrite the Nginx config to redirect HTTP to HTTPS automatically:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Common errors and what causes them
| Symptom | Cause | Fix |
|---|---|---|
| 502 Bad Gateway | Gunicorn isn't running, or the socket path in Nginx doesn't match the one in the systemd file | Run systemctl status myapp and journalctl -u myapp -n 50, then diff the socket path in both config files |
| Permission denied on socket | Nginx (running as www-data) can't reach a socket owned by a different group | Set Group=www-data in the systemd unit so both processes share group access |
| Static files 404 | CSS/JS served through Django instead of Nginx, or collectstatic was never run | Run collectstatic and confirm the alias path in Nginx matches STATIC_ROOT exactly |
| App works over HTTP but not HTTPS | Django's ALLOWED_HOSTS or CSRF_TRUSTED_ORIGINS doesn't include the domain | Add the domain to both settings and restart Gunicorn |
| Changes to code don't show up | Gunicorn workers cache the old process in memory | Run sudo systemctl restart myapp after every deploy - a git pull alone doesn't reload the process |
Prevention: a short production checklist
- Set
DEBUG = Falsein Django (or disable Flask's debug mode) before going live - a debug page leaking your source and settings to the public is a bigger problem than any 500 error. - Never point Gunicorn's bind address at
0.0.0.0on a production box; only Nginx should be internet-facing. - Add a deploy script that runs
git pull,pip install -r requirements.txt,collectstatic/migrations, andsystemctl restart myappas one command - doing these steps by hand from memory is how staging drifts from production. - Watch memory, not just CPU. Gunicorn workers that leak memory over days will eventually get killed by the OOM killer with no obvious error in your app logs - check
dmesgif a worker vanishes without a stack trace. - Set up log rotation for Gunicorn's access/error logs if you're writing them to files, so a busy app doesn't fill the disk over a few months.
Frequently asked questions
Do I still need Gunicorn if I'm using uWSGI or Daphne instead?
No - pick one WSGI/ASGI server, not several. Gunicorn is the most common choice for Django and Flask; use Daphne or Uvicorn instead if your app is ASGI-based (Django Channels, FastAPI). The Nginx and systemd setup in this guide works the same way regardless of which one you choose.
Why use a Unix socket instead of just binding Gunicorn to 127.0.0.1:8000?
Both work, but a socket avoids TCP overhead for local traffic and makes port conflicts a non-issue when you're running several apps on one VPS. If you do use a TCP port, make sure it's not opened in the firewall - it should only ever be reached by Nginx on the same machine.
My systemd service shows 'active (running)' but the site still 502s. What now?
Check that the socket file actually exists at the path Nginx expects (`ls -la /var/www/myapp/myapp.sock`) and that its group matches Nginx's user. Also confirm SELinux or AppArmor isn't blocking Nginx from reaching outside its normal directories, which shows up in `/var/log/audit/audit.log` on some distros.
How do I run this app alongside a WordPress site on the same VPS?
Give each site its own Nginx server block with a distinct `server_name`, and its own systemd service and socket file if it's a Python app. Nginx routes by hostname, so WordPress on PHP-FPM and a Django app on Gunicorn can sit on the same box without conflicting, as long as each has its own socket or port.
Should I use Docker instead of setting all this up manually?
Docker is worth it once you're managing several services or want identical environments across staging and production. For a single Python app on a single VPS, the systemd + Nginx setup here has less overhead and fewer moving parts to debug at 2 a.m.