How to Run Python Apps with Gunicorn and Nginx on a VPS
Deploy a Flask or FastAPI application on Ubuntu with a virtualenv, Gunicorn systemd service, Nginx reverse proxy, TLS, environment files, logging, and a checklist for 502 errors and worker sizing.

The built-in Flask server and uvicorn --reload are for development. On a public VPS you want a process manager that restarts workers, binds to localhost, and sits behind a reverse proxy that handles TLS and slow clients. Gunicorn is the usual WSGI choice for Flask and Django. FastAPI can run under Gunicorn with an Uvicorn worker class. Nginx (or Caddy) terminates HTTPS and forwards to 127.0.0.1:8000.
This guide walks through a layout that survives reboots: project in /srv/app, virtualenv, .env with secrets, a gunicorn.service unit, Nginx server block, Let's Encrypt, and log files you can actually grep. We will also talk about worker counts, timeouts, and why 502 Bad Gateway is almost never 'Nginx is broken' — it is almost always Gunicorn not running, bound to the wrong socket, or crashing on import. The example uses Flask, with notes for FastAPI where the command differs.
Why this stack
Gunicorn preforks worker processes. Each worker handles one request at a time unless you use a different worker class. Nginx buffers slow clients so workers are not stuck sending bytes to a mobile network. systemd restarts the app if it dies. Together this is boring, which is what you want at 3 a.m.
- Gunicorn: stable WSGI/ASGI process model
- systemd: start on boot, restart on crash, journald logs
- Nginx: TLS, static files, request size limits, gzip
- venv: system Python stays clean
- localhost bind: the app is not reachable except through Nginx
Requirements
Python 3.10+ on Ubuntu 22.04/24.04 is fine. Do not run pip as root into system site-packages. You need a domain for TLS. If you prefer Caddy as the proxy, the Gunicorn unit in this article stays the same — only the front-end config changes (see the Caddy article).
- Ubuntu 22.04 or 24.04 VPS
- Your application with a requirements.txt or equivalent
- A WSGI entrypoint (for Flask: app:app) or ASGI (for FastAPI: app:app with uvicorn workers)
- Domain A record for HTTPS
Step 1: System packages, user and project directory
Create a system user that cannot log in interactively, own the code, and run Gunicorn. Installing python3-venv and build tools avoids pip failures on packages that still compile C extensions.
ssh root@YOUR_VPS_IP
apt update && apt -y upgrade
apt -y install python3 python3-venv python3-pip python3-dev build-essential nginx curl
adduser --system --group --home /srv/app appuser
mkdir -p /srv/app
chown appuser:appuser /srv/app
# Copy your code (example):
# rsync -a --delete ./myproject/ appuser@YOUR_VPS_IP:/srv/app/Step 2: Virtualenv and dependencies
Create the venv as appuser so file ownership is correct. Pin versions in production. After install, confirm you can import the app in a one-off gunicorn --check-config or a Python import. Import errors here are the same errors that become 502 later.
sudo -u appuser -H bash -lc '
cd /srv/app
python3 -m venv /srv/app/venv
/srv/app/venv/bin/pip install --upgrade pip
/srv/app/venv/bin/pip install -r /srv/app/requirements.txt gunicorn
'
# Flask example check:
sudo -u appuser -H /srv/app/venv/bin/python -c "from app import app; print('import ok')"Step 3: Environment file
Do not hardcode SECRET_KEY or database URLs in the systemd unit in a way that ends up in world-readable git. Use EnvironmentFile. chmod 640, owner root, group appuser (or owner appuser if you prefer).
cat >/srv/app/.env <<'EOF'
FLASK_ENV=production
SECRET_KEY=replace-with-openssl-rand-hex-32
DATABASE_URL=postgresql://app:password@127.0.0.1:5432/app
EOF
chown appuser:appuser /srv/app/.env
chmod 600 /srv/app/.envStep 4: Gunicorn systemd service
Bind to 127.0.0.1:8000, not 0.0.0.0, unless you have a reason to skip Nginx. Worker count is often (2 x CPU) + 1 for sync workers; on a 2 vCPU VPS that is 5, which may be too many if each worker loads a heavy ML model — then use 2–3. For FastAPI, set --worker-class uvicorn.workers.UvicornWorker and install uvicorn. Timeouts should exceed your slowest honest request, not 30 seconds if you have 2-minute exports.
cat >/etc/systemd/system/gunicorn.service <<'EOF'
[Unit]
Description=Gunicorn for the web app
After=network.target
[Service]
User=appuser
Group=appuser
WorkingDirectory=/srv/app
EnvironmentFile=/srv/app/.env
ExecStart=/srv/app/venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 --timeout 60 --access-logfile - --error-logfile - app:app
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now gunicorn
systemctl status gunicorn --no-pager
ss -tulpn | grep 8000Step 5: Nginx reverse proxy and TLS
Nginx listens on 80/443 and proxies to Gunicorn. client_max_body_size matters for uploads. proxy_read_timeout should match or exceed Gunicorn's timeout. After the server block works on HTTP, issue a certificate with Certbot (or switch the front to Caddy). The snippet below is HTTP-only so you can test; then run Certbot which can edit the file.
cat >/etc/nginx/sites-available/app <<'EOF'
server {
listen 80;
server_name app.example.com;
client_max_body_size 20m;
location /static/ {
alias /srv/app/static/;
}
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 90s;
}
}
EOF
ln -sf /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
nginx -t && systemctl reload nginx
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
apt -y install certbot python3-certbot-nginx
certbot --nginx -d app.example.comStep 6: Static files, permissions and Flask/FastAPI specifics
Nginx should serve static files if you can; it is faster than Gunicorn. Django collectstatic, Flask send_from_directory for tiny apps, or a CDN later. The appuser must be able to read the tree. If you use FastAPI, install uvicorn[standard] in the venv and change ExecStart to use UvicornWorker. If you use Unix sockets instead of TCP, point proxy_pass at the socket and match permissions so www-data can write to it.
# FastAPI ExecStart example:
# ExecStart=/srv/app/venv/bin/gunicorn -k uvicorn.workers.UvicornWorker --workers 2 --bind 127.0.0.1:8000 app:app
# Unix socket variant:
# --bind unix:/run/gunicorn/app.sock
# Nginx: proxy_pass http://unix:/run/gunicorn/app.sock:
chown -R appuser:appuser /srv/appWorkers, memory and zero-downtime reloads
Sync Gunicorn workers are simple and enough for CPU-light request/response APIs. If you need many concurrent slow I/O waits, consider gevent or an ASGI worker — measure, do not guess. Each worker loads your app; RAM ~= workers times app RSS. A 2 GB VPS with 8 workers of a 300 MB app will swap and feel 'randomly slow'. systemctl reload gunicorn (HUP) can restart workers with the new code if you deployed files in place; a full restart is clearer when dependencies change.
# After git pull / rsync of new code:
sudo -u appuser -H /srv/app/venv/bin/pip install -r /srv/app/requirements.txt
systemctl restart gunicorn
curl -I https://app.example.com/healthTroubleshooting 502 and silent crashes
502 means Nginx could not get a valid response from upstream. journalctl -u gunicorn -e is the first command. Common causes: wrong module:app name, missing .env key, Postgres not running, bound to 127.0.0.1 but Nginx on another host, SELinux (rare on Ubuntu), or the app listening on IPv6 only. 504 is a timeout. 301 loops happen when the app redirects to HTTP while X-Forwarded-Proto is ignored.
- systemctl status gunicorn — is it active?
- journalctl -u gunicorn -n 100 — ImportError, missing env, database
- curl -v http://127.0.0.1:8000/ from the VPS — if this fails, Nginx is innocent
- nginx -t and error.log — upstream prematurely closed connection
- ss -tulpn | grep 8000 — nothing listening
- Disk full — workers crash in mysterious ways
Security
The app never binds publicly. Secrets stay in .env. Keep the venv and OS patched. Do not run Gunicorn as root. If you handle logins, set session cookies Secure and SameSite, and configure the framework to trust X-Forwarded-Proto from Nginx only. Rate-limit login routes in Nginx or in the app.
- bind 127.0.0.1 or a unix socket
- chmod 600 .env
- Non-root User= in systemd
- TLS via Certbot or Caddy
- Disable debug mode and auto-reload in production
Tips
- Add a /health route that checks DB connectivity for Compose and load balancers
- Ship static assets with a cache-control header in Nginx
- Use a separate worker or queue (Redis + systemd) for emails and heavy jobs
- Pin gunicorn and uvicorn versions in requirements.txt
- Take a snapshot before the first production cutover
A Python app on a VPS is production-ready when it runs under Gunicorn as a systemd service, binds only to localhost, and is reached through Nginx or Caddy with TLS. Put secrets in an EnvironmentFile, size workers to RAM rather than a blog post formula, and debug 502 from the Gunicorn journal first. Once this path is documented for your repo, every later deploy is rsync or git pull, pip install, and systemctl restart gunicorn.