Zurück zum Blog
August 19, 2026Anleitungen

Python-Apps mit Gunicorn und Nginx auf einem VPS betreiben

Eine Flask- oder FastAPI-Anwendung auf Ubuntu mit virtualenv, Gunicorn-systemd-Dienst, Nginx-Reverse-Proxy, TLS, Umgebungsdateien, Logging und einer Checkliste für 502-Fehler und Worker-Dimensionierung bereitstellen.

Python-Apps mit Gunicorn und Nginx auf einem VPS betreiben

Der eingebaute Flask-Server und uvicorn --reload sind für die Entwicklung. Auf einem öffentlichen VPS wollen Sie einen Prozessmanager, der Worker neu startet, an localhost bindet und hinter einem Reverse-Proxy sitzt, der TLS und langsame Clients übernimmt. Gunicorn ist die übliche WSGI-Wahl für Flask und Django. FastAPI kann unter Gunicorn mit einer Uvicorn-Worker-Klasse laufen. Nginx (oder Caddy) terminiert HTTPS und leitet nach 127.0.0.1:8000 weiter.

Diese Anleitung führt durch ein Layout, das Reboots überlebt: Projekt in /srv/app, virtualenv, .env mit Secrets, eine gunicorn.service-Unit, Nginx-Server-Block, Let's Encrypt und Logdateien, die Sie wirklich greppen können. Wir sprechen auch über Worker-Anzahl, Timeouts und warum 502 Bad Gateway fast nie „Nginx ist kaputt“ bedeutet — fast immer läuft Gunicorn nicht, ist an den falschen Socket gebunden oder stürzt beim Import ab. Das Beispiel nutzt Flask, mit Hinweisen zu FastAPI, wo der Befehl abweicht.

Warum dieser Stack

Gunicorn forkt Worker-Prozesse vor. Jeder Worker bearbeitet eine Anfrage zur Zeit, sofern Sie keine andere Worker-Klasse nutzen. Nginx puffert langsame Clients, damit Worker nicht Bytes in ein Mobilnetz schicken. systemd startet die App neu, wenn sie stirbt. Zusammen ist das langweilig — genau das, was Sie um 3 Uhr nachts wollen.

  • Gunicorn: stabiles WSGI/ASGI-Prozessmodell
  • systemd: Start beim Boot, Neustart nach Absturz, journald-Logs
  • Nginx: TLS, statische Dateien, Request-Größenlimits, gzip
  • venv: System-Python bleibt sauber
  • localhost-Bind: die App ist nur über Nginx erreichbar

Voraussetzungen

Python 3.10+ auf Ubuntu 22.04/24.04 ist in Ordnung. pip nicht als root in systemweite site-packages ausführen. Für TLS brauchen Sie eine Domain. Bevorzugen Sie Caddy als Proxy, bleibt die Gunicorn-Unit in diesem Artikel gleich — nur die Frontend-Config ändert sich (siehe Caddy-Artikel).

  • Ubuntu 22.04 oder 24.04 VPS
  • Ihre Anwendung mit requirements.txt oder Entsprechung
  • Ein WSGI-Entrypoint (Flask: app:app) oder ASGI (FastAPI: app:app mit uvicorn-Workern)
  • Domain-A-Record für HTTPS

Schritt 1: Systempakete, Benutzer und Projektverzeichnis

Legen Sie einen Systembenutzer ohne interaktives Login an, dem der Code gehört und der Gunicorn ausführt. python3-venv und Build-Tools vermeiden pip-Fehler bei Paketen, die noch C-Erweiterungen kompilieren.

bash
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/

Schritt 2: Virtualenv und Abhängigkeiten

Das venv als appuser anlegen, damit die Dateibesitzer stimmen. In Produktion Versionen pinnen. Nach der Installation prüfen, ob Sie die App in einem einmaligen gunicorn --check-config oder einem Python-Import laden können. Importfehler hier sind dieselben, die später zu 502 werden.

bash
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')"

Schritt 3: Umgebungsdatei

SECRET_KEY oder Datenbank-URLs nicht so in der systemd-Unit hartcodieren, dass sie in weltlesbarem Git landen. EnvironmentFile nutzen. chmod 640, Owner root, Gruppe appuser (oder Owner appuser, wenn Sie das bevorzugen).

bash
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/.env

Schritt 4: Gunicorn-systemd-Dienst

An 127.0.0.1:8000 binden, nicht 0.0.0.0, außer Sie wollen Nginx überspringen. Die Worker-Zahl ist oft (2 × CPU) + 1 für Sync-Worker; auf einem 2-vCPU-VPS sind das 5, was zu viel sein kann, wenn jeder Worker ein schweres ML-Modell lädt — dann 2–3. Für FastAPI --worker-class uvicorn.workers.UvicornWorker setzen und uvicorn installieren. Timeouts sollten die langsamste ehrliche Anfrage übersteigen, nicht 30 Sekunden, wenn Sie 2-Minuten-Exports haben.

bash
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 8000

Schritt 5: Nginx-Reverse-Proxy und TLS

Nginx lauscht auf 80/443 und proxyt zu Gunicorn. client_max_body_size zählt bei Uploads. proxy_read_timeout sollte Gunicorns Timeout erreichen oder überschreiten. Funktioniert der Server-Block per HTTP, ein Zertifikat mit Certbot ausstellen (oder das Frontend auf Caddy umstellen). Das Snippet unten ist nur HTTP zum Testen; danach Certbot, das die Datei anpassen kann.

bash
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.com

Schritt 6: Statische Dateien, Rechte und Flask/FastAPI-Besonderheiten

Nginx sollte statische Dateien ausliefern, wenn möglich; das ist schneller als Gunicorn. Django collectstatic, Flask send_from_directory für Mini-Apps oder später ein CDN. appuser muss den Baum lesen können. Bei FastAPI uvicorn[standard] im venv installieren und ExecStart auf UvicornWorker umstellen. Bei Unix-Sockets statt TCP proxy_pass auf den Socket zeigen und Rechte so setzen, dass www-data schreiben kann.

bash
# 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/app

Worker, Speicher und Reloads ohne Downtime

Sync-Gunicorn-Worker sind einfach und reichen für CPU-leichte Request/Response-APIs. Brauchen Sie viele gleichzeitige langsame I/O-Wartezeiten, gevent oder einen ASGI-Worker in Betracht ziehen — messen, nicht raten. Jeder Worker lädt Ihre App; RAM ≈ Worker mal App-RSS. Ein 2-GB-VPS mit 8 Workern einer 300-MB-App swapped und fühlt sich „zufällig langsam“ an. systemctl reload gunicorn (HUP) kann Worker mit neuem Code neu starten, wenn Dateien an Ort und Stelle deployed wurden; ein voller Restart ist klarer, wenn sich Abhängigkeiten ändern.

bash
# 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/health

502 und stille Abstürze beheben

502 heißt, Nginx bekam keine gültige Antwort vom Upstream. journalctl -u gunicorn -e ist der erste Befehl. Häufige Ursachen: falscher module:app-Name, fehlender .env-Schlüssel, Postgres läuft nicht, an 127.0.0.1 gebunden aber Nginx auf einem anderen Host, SELinux (selten auf Ubuntu) oder die App lauscht nur auf IPv6. 504 ist ein Timeout. 301-Schleifen entstehen, wenn die App nach HTTP umleitet, während X-Forwarded-Proto ignoriert wird.

  • systemctl status gunicorn — ist der Dienst aktiv?
  • journalctl -u gunicorn -n 100 — ImportError, fehlende Env, Datenbank
  • curl -v http://127.0.0.1:8000/ vom VPS — schlägt das fehl, ist Nginx unschuldig
  • nginx -t und error.log — upstream prematurely closed connection
  • ss -tulpn | grep 8000 — nichts lauscht
  • Platte voll — Worker stürzen auf rätselhafte Weise ab

Sicherheit

Die App bindet niemals öffentlich. Secrets bleiben in .env. venv und OS gepatcht halten. Gunicorn nicht als root betreiben. Bei Logins Session-Cookies Secure und SameSite setzen und das Framework so konfigurieren, dass es X-Forwarded-Proto nur von Nginx vertraut. Login-Routen in Nginx oder in der App rate-limiten.

  • an 127.0.0.1 oder einen Unix-Socket binden
  • chmod 600 .env
  • Non-root User= in systemd
  • TLS über Certbot oder Caddy
  • Debug-Modus und Auto-Reload in Produktion deaktivieren

Tipps

  • Eine /health-Route, die die DB-Verbindung prüft, für Compose und Load Balancer
  • Statische Assets mit Cache-Control-Header in Nginx ausliefern
  • Separaten Worker oder Queue (Redis + systemd) für E-Mails und schwere Jobs
  • gunicorn- und uvicorn-Versionen in requirements.txt pinnen
  • Vor dem ersten Produktions-Cutover einen Snapshot machen

Eine Python-App auf einem VPS ist produktionsreif, wenn sie unter Gunicorn als systemd-Dienst läuft, nur an localhost bindet und über Nginx oder Caddy mit TLS erreichbar ist. Secrets in ein EnvironmentFile, Worker nach RAM statt nach Blog-Formel dimensionieren und 502 zuerst im Gunicorn-Journal debuggen. Ist dieser Weg für Ihr Repo dokumentiert, ist jedes spätere Deploy rsync oder git pull, pip install und systemctl restart gunicorn.