Come eseguire app Python con Gunicorn e Nginx su un VPS
Distribuire un’applicazione Flask o FastAPI su Ubuntu con un virtualenv, un servizio systemd Gunicorn, un reverse proxy Nginx, TLS, file di ambiente, logging e una lista di controllo per gli errori 502 e il dimensionamento dei worker.

Il server Flask integrato e uvicorn --reload sono per lo sviluppo. Su un VPS pubblico vuoi un gestore di processi che riavvii i worker, si associ a localhost e stia dietro un reverse proxy che gestisce TLS e client lenti. Gunicorn è la scelta WSGI usuale per Flask e Django. FastAPI può girare sotto Gunicorn con una classe worker Uvicorn. Nginx (o Caddy) termina HTTPS e inoltra a 127.0.0.1:8000.
Questa guida percorre un layout che sopravvive ai riavvii: progetto in /srv/app, virtualenv, .env con i segreti, un’unità gunicorn.service, un blocco server Nginx, Let's Encrypt e file di log che puoi davvero greppare. Parleremo anche del numero di worker, dei timeout e del perché 502 Bad Gateway quasi mai è «Nginx è rotto»: quasi sempre Gunicorn non gira, è associato al socket sbagliato o crasha all’import. L’esempio usa Flask, con note per FastAPI dove il comando differisce.
Perché questo stack
Gunicorn pre-esegue fork dei processi worker. Ogni worker gestisce una richiesta alla volta a meno che tu non usi una classe worker diversa. Nginx bufferizza i client lenti così i worker non restano bloccati a inviare byte verso una rete mobile. systemd riavvia l’app se muore. Insieme è noioso, che è ciò che vuoi alle tre di notte.
- Gunicorn: modello di processo WSGI/ASGI stabile
- systemd: avvio al boot, riavvio in caso di crash, log journald
- Nginx: TLS, file statici, limiti di dimensione delle richieste, gzip
- venv: il Python di sistema resta pulito
- bind su localhost: l’app non è raggiungibile se non tramite Nginx
Requisiti
Python 3.10+ su Ubuntu 22.04/24.04 va bene. Non eseguire pip come root nei site-packages di sistema. Ti serve un dominio per TLS. Se preferisci Caddy come proxy, l’unità Gunicorn di questo articolo resta uguale: cambia solo la config del front-end (vedi l’articolo su Caddy).
- VPS Ubuntu 22.04 o 24.04
- La tua applicazione con un requirements.txt o equivalente
- Un entrypoint WSGI (per Flask: app:app) o ASGI (per FastAPI: app:app con worker uvicorn)
- Record A del dominio per HTTPS
Passo 1: Pacchetti di sistema, utente e directory del progetto
Crea un utente di sistema che non può accedere in modo interattivo, proprietario del codice, e che esegue Gunicorn. Installare python3-venv e gli strumenti di build evita fallimenti pip su pacchetti che compilano ancora estensioni C.
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/Passo 2: Virtualenv e dipendenze
Crea il venv come appuser così la proprietà dei file è corretta. Fissa le versioni in produzione. Dopo l’installazione, conferma di poter importare l’app con un gunicorn --check-config occasionale o un import Python. Gli errori di import qui sono gli stessi che più tardi diventano 502.
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')"Passo 3: File di ambiente
Non inserire SECRET_KEY o URL di database nell’unità systemd in un modo che finisca in un git leggibile da tutti. Usa EnvironmentFile. chmod 640, proprietario root, gruppo appuser (o proprietario appuser se preferisci).
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/.envPasso 4: Servizio systemd Gunicorn
Associa a 127.0.0.1:8000, non a 0.0.0.0, a meno che tu abbia un motivo per saltare Nginx. Il numero di worker è spesso (2 × CPU) + 1 per i worker sync; su un VPS 2 vCPU sono 5, che può essere troppo se ogni worker carica un modello ML pesante: allora usa 2–3. Per FastAPI imposta --worker-class uvicorn.workers.UvicornWorker e installa uvicorn. I timeout devono superare la richiesta onesta più lenta, non 30 secondi se hai export da 2 minuti.
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 8000Passo 5: Reverse proxy Nginx e TLS
Nginx ascolta su 80/443 e fa proxy verso Gunicorn. client_max_body_size conta per gli upload. proxy_read_timeout deve eguagliare o superare il timeout di Gunicorn. Quando il blocco server funziona in HTTP, emetti un certificato con Certbot (o passa il front a Caddy). Lo snippet sotto è solo HTTP per testare; poi esegui Certbot, che può modificare il 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.comPasso 6: File statici, permessi e specificità Flask/FastAPI
Nginx dovrebbe servire i file statici se puoi; è più veloce di Gunicorn. Django collectstatic, Flask send_from_directory per app minuscole, o un CDN più tardi. appuser deve poter leggere l’albero. Se usi FastAPI, installa uvicorn[standard] nel venv e cambia ExecStart per usare UvicornWorker. Se usi socket Unix invece di TCP, punta proxy_pass al socket e allinea i permessi così www-data può scriverci.
# 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/appWorker, memoria e reload a zero downtime
I worker sync di Gunicorn sono semplici e bastano per API richiesta/risposta leggere in CPU. Se ti servono molte attese I/O lente concorrenti, valuta gevent o un worker ASGI: misura, non indovinare. Ogni worker carica la tua app; RAM ≈ worker per l’RSS dell’app. Un VPS da 2 GB con 8 worker di un’app da 300 MB farà swap e sembrerà «lento a caso». systemctl reload gunicorn (HUP) può riavviare i worker con il codice nuovo se hai distribuito i file sul posto; un riavvio completo è più chiaro quando cambiano le dipendenze.
# 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/healthRisolvere 502 e crash silenziosi
502 significa che Nginx non ha ottenuto una risposta valida dall’upstream. journalctl -u gunicorn -e è il primo comando. Cause comuni: nome module:app sbagliato, chiave .env mancante, Postgres spento, associato a 127.0.0.1 ma Nginx su un altro host, SELinux (raro su Ubuntu) o l’app in ascolto solo su IPv6. 504 è un timeout. I loop 301 succedono quando l’app reindirizza a HTTP mentre X-Forwarded-Proto viene ignorato.
- systemctl status gunicorn — è attivo?
- journalctl -u gunicorn -n 100 — ImportError, env mancante, database
- curl -v http://127.0.0.1:8000/ dal VPS — se fallisce, Nginx è innocente
- nginx -t e error.log — upstream prematurely closed connection
- ss -tulpn | grep 8000 — niente in ascolto
- Disco pieno — i worker crashano in modi misteriosi
Sicurezza
L’app non si associa mai in pubblico. I segreti restano in .env. Tieni venv e sistema patchati. Non eseguire Gunicorn come root. Se gestisci accessi, imposta i cookie di sessione Secure e SameSite e configura il framework perché si fidi di X-Forwarded-Proto solo da Nginx. Limita la frequenza delle rotte di login in Nginx o nell’app.
- associare 127.0.0.1 o un socket Unix
- chmod 600 .env
- User= non root in systemd
- TLS tramite Certbot o Caddy
- Disattiva la modalità debug e l’auto-reload in produzione
Consigli
- Aggiungi una rotta /health che verifica la connettività al DB per Compose e i load balancer
- Consegna gli asset statici con un’intestazione cache-control in Nginx
- Usa un worker o una coda separati (Redis + systemd) per email e lavori pesanti
- Fissa le versioni di gunicorn e uvicorn in requirements.txt
- Fai uno snapshot prima del primo passaggio in produzione
Un’app Python su un VPS è pronta per la produzione quando gira sotto Gunicorn come servizio systemd, si associa solo a localhost ed è raggiunta tramite Nginx o Caddy con TLS. Metti i segreti in un EnvironmentFile, dimensiona i worker in base alla RAM e non a una formula da blog, e fai debug dei 502 prima dal journal di Gunicorn. Una volta documentato questo percorso per il tuo repo, ogni deploy successivo è rsync o git pull, pip install e systemctl restart gunicorn.