Python-apps draaien met Gunicorn en Nginx op een VPS
Een Flask- of FastAPI-toepassing op Ubuntu uitrollen met een virtualenv, Gunicorn-systemd-service, Nginx-reverse-proxy, TLS, omgevingsbestanden, logging en een checklist voor 502-fouten en worker-dimensionering.

De ingebouwde Flask-server en uvicorn --reload zijn voor ontwikkeling. Op een openbare VPS wil je een procesbeheerder die workers herstart, bindt aan localhost en achter een reverse proxy zit die TLS en trage clients afhandelt. Gunicorn is de gebruikelijke WSGI-keuze voor Flask en Django. FastAPI kan onder Gunicorn draaien met een Uvicorn-workerklasse. Nginx (of Caddy) beëindigt HTTPS en stuurt door naar 127.0.0.1:8000.
Deze gids loopt een indeling door die reboots overleeft: project in /srv/app, virtualenv, .env met secrets, een gunicorn.service-unit, Nginx-serverblok, Let's Encrypt en logbestanden die je écht kunt greppen. We hebben het ook over workeraantallen, time-outs en waarom 502 Bad Gateway bijna nooit «Nginx is stuk» is — bijna altijd draait Gunicorn niet, is het aan de verkeerde socket gebonden, of crasht het bij import. Het voorbeeld gebruikt Flask, met notities voor FastAPI waar het commando verschilt.
Waarom deze stack
Gunicorn voorforkt workerprocessen. Elke worker behandelt één verzoek tegelijk tenzij je een andere workerklasse gebruikt. Nginx buffert trage clients zodat workers niet vastzitten bytes naar een mobiel netwerk te sturen. systemd herstart de app als die sterft. Samen is dat saai, en dat is wat je om 3 uur ’s nachts wilt.
- Gunicorn: stabiel WSGI/ASGI-procesmodel
- systemd: starten bij boot, herstarten bij crash, journald-logs
- Nginx: TLS, statische bestanden, limieten voor verzoekgrootte, gzip
- venv: systeem-Python blijft schoon
- localhost-bind: de app is alleen via Nginx bereikbaar
Vereisten
Python 3.10+ op Ubuntu 22.04/24.04 is prima. Voer pip niet als root uit naar systeem-site-packages. Je hebt een domein nodig voor TLS. Als je Caddy als proxy prefereert, blijft de Gunicorn-unit in dit artikel hetzelfde — alleen de frontendconfig verandert (zie het Caddy-artikel).
- Ubuntu 22.04- of 24.04-VPS
- Jouw toepassing met een requirements.txt of equivalent
- Een WSGI-entrypoint (voor Flask: app:app) of ASGI (voor FastAPI: app:app met uvicorn-workers)
- Domein-A-record voor HTTPS
Stap 1: Systeempakketten, gebruiker en projectmap
Maak een systeemgebruiker aan die niet interactief kan inloggen, eigenaar is van de code en Gunicorn draait. python3-venv en buildtools installeren voorkomt pip-fouten bij pakketten die nog C-extensies compileren.
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/Stap 2: Virtualenv en afhankelijkheden
Maak de venv aan als appuser zodat bestandseigendom klopt. Pin versies in productie. Bevestig na installatie dat je de app kunt importeren met een eenmalige gunicorn --check-config of een Python-import. Importfouten hier zijn dezelfde fouten die later 502 worden.
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')"Stap 3: Omgevingsbestand
Codeer SECRET_KEY of database-URL’s niet zo in de systemd-unit dat ze in wereldleesbare git belanden. Gebruik EnvironmentFile. chmod 640, eigenaar root, groep appuser (of eigenaar appuser als je dat liever hebt).
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/.envStap 4: Gunicorn-systemd-service
Bind aan 127.0.0.1:8000, niet 0.0.0.0, tenzij je een reden hebt om Nginx over te slaan. Het workeraantal is vaak (2 × CPU) + 1 voor sync-workers; op een 2-vCPU-VPS is dat 5, wat te veel kan zijn als elke worker een zwaar ML-model laadt — gebruik dan 2–3. Voor FastAPI zet je --worker-class uvicorn.workers.UvicornWorker en installeer je uvicorn. Time-outs moeten je langzaamste eerlijke verzoek overschrijden, geen 30 seconden als je 2-minuten-exports hebt.
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 8000Stap 5: Nginx-reverse-proxy en TLS
Nginx luistert op 80/443 en proxyt naar Gunicorn. client_max_body_size telt bij uploads. proxy_read_timeout moet gelijk zijn aan of groter dan de time-out van Gunicorn. Werkt het serverblok op HTTP, geef dan een certificaat uit met Certbot (of schakel de voorkant naar Caddy). Het snippet hieronder is alleen HTTP om te testen; daarna Certbot, dat het bestand kan aanpassen.
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.comStap 6: Statische bestanden, rechten en Flask/FastAPI-bijzonderheden
Nginx zou statische bestanden moeten serveren als het kan; dat is sneller dan Gunicorn. Django collectstatic, Flask send_from_directory voor minuscule apps, of later een CDN. appuser moet de boom kunnen lezen. Gebruik je FastAPI, installeer dan uvicorn[standard] in de venv en wijzig ExecStart om UvicornWorker te gebruiken. Gebruik je Unix-sockets in plaats van TCP, wijs proxy_pass dan naar de socket en stem rechten af zodat www-data ernaar kan schrijven.
# 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, geheugen en reloads zonder downtime
Sync-Gunicorn-workers zijn eenvoudig en genoeg voor CPU-lichte request/response-API’s. Heb je veel gelijktijdige trage I/O-wachttijden nodig, overweeg dan gevent of een ASGI-worker — meet, gok niet. Elke worker laadt je app; RAM ≈ workers maal app-RSS. Een 2 GB-VPS met 8 workers van een 300 MB-app gaat swappen en voelt «willekeurig traag». systemctl reload gunicorn (HUP) kan workers met de nieuwe code herstarten als je bestanden ter plaatse hebt gedeployed; een volledige herstart is duidelijker als afhankelijkheden wijzigen.
# 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/health502 en stille crashes oplossen
502 betekent dat Nginx geen geldig antwoord van upstream kreeg. journalctl -u gunicorn -e is het eerste commando. Veelvoorkomende oorzaken: verkeerde module:app-naam, ontbrekende .env-sleutel, Postgres draait niet, gebonden aan 127.0.0.1 maar Nginx op een andere host, SELinux (zeldzaam op Ubuntu), of de app luistert alleen op IPv6. 504 is een time-out. 301-lussen ontstaan als de app naar HTTP omleidt terwijl X-Forwarded-Proto wordt genegeerd.
- systemctl status gunicorn — is hij actief?
- journalctl -u gunicorn -n 100 — ImportError, ontbrekende env, database
- curl -v http://127.0.0.1:8000/ vanaf de VPS — faalt dit, dan is Nginx onschuldig
- nginx -t en error.log — upstream prematurely closed connection
- ss -tulpn | grep 8000 — niets luistert
- Schijf vol — workers crashen op mysterieuze manieren
Beveiliging
De app bindt nooit publiek. Secrets blijven in .env. Houd venv en OS gepatcht. Draai Gunicorn niet als root. Als je logins afhandelt, zet sessiecookies op Secure en SameSite en configureer het framework om X-Forwarded-Proto alleen van Nginx te vertrouwen. Rate-limit loginroutes in Nginx of in de app.
- bind 127.0.0.1 of een Unix-socket
- chmod 600 .env
- Niet-root User= in systemd
- TLS via Certbot of Caddy
- Schakel debugmodus en auto-reload in productie uit
Tips
- Voeg een /health-route toe die DB-connectiviteit controleert voor Compose en load balancers
- Lever statische assets met een cache-control-header in Nginx
- Gebruik een aparte worker of wachtrij (Redis + systemd) voor e-mail en zware jobs
- Pin gunicorn- en uvicorn-versies in requirements.txt
- Maak een snapshot voor de eerste productieswitch
Een Python-app op een VPS is productieklaar als die onder Gunicorn als systemd-service draait, alleen aan localhost bindt en via Nginx of Caddy met TLS wordt bereikt. Zet secrets in een EnvironmentFile, dimensioner workers naar RAM in plaats van naar een blogformule, en debug 502 eerst vanuit het Gunicorn-journal. Als dit pad voor je repo is gedocumenteerd, is elke latere deploy rsync of git pull, pip install en systemctl restart gunicorn.