Das VPS-Setup, das wirklich funktioniert: Node.js, PM2, Nginx und Zero-Downtime-Deploys
Das exakte VPS-Deployment-Setup, das ich in Produktion verwende — Ubuntu-Absicherung, PM2-Cluster-Modus, Nginx-Reverse-Proxy, SSL und ein Deploy-Script, das mich noch nie im Stich gelassen hat. Keine Theorie, nur das, was funktioniert.
Dieser Blog läuft auf einem 10$/Monat VPS. Nicht Vercel, nicht AWS, nicht ein Kubernetes-Cluster, der von einem sechsköpfigen Team verwaltet wird. Eine einzelne Ubuntu-Box mit Nginx, PM2 und einem Bash-Script, das in unter 30 Sekunden deployt.
Ich habe die anderen Wege probiert. Ich habe Vercel verwendet (großartig, bis man Cron-Jobs, persistente WebSockets oder einfach Kontrolle braucht). Ich habe AWS verwendet (großartig, wenn man gerne die halbe Zeit mit IAM-Policies verbringt). Am Ende lande ich immer wieder bei einem VPS.
Aber hier ist das Problem: Jedes "Deploy auf VPS"-Tutorial im Internet hört beim Happy Path auf. Sie zeigen dir, wie man Node.js installiert und node server.js ausführt und nennen es Produktion. Dann wird dein Server per SSH brute-forced, dein Prozess stirbt um 3 Uhr morgens, weil niemand einen Process Manager eingerichtet hat, und dein SSL-Zertifikat ist vor drei Monaten abgelaufen.
Dies ist die Anleitung, die ich mir gewünscht hätte. Alles hier ist kampferprobt — genau dieses Setup liefert die Seite aus, die du gerade liest.
Beginne mit Sicherheit, nicht mit Code#
Bevor du auch nur an Node.js denkst, sichere die Box ab. Frische VPS-Instanzen sind Ziele. Automatisierte Bots fangen innerhalb von Minuten nach der Bereitstellung an, deinen SSH-Port zu attackieren.
Erstelle einen Nicht-Root-Benutzer#
adduser deploy
usermod -aG sudo deployRichte SSH-Key-Authentifizierung ein#
Auf deinem lokalen Rechner:
ssh-copy-id deploy@your-server-ipDann deaktiviere die Passwort-Authentifizierung komplett:
sudo nano /etc/ssh/sshd_configPasswordAuthentication no
PermitRootLogin nosudo systemctl restart sshdWenn du das überspringst, wirst du innerhalb von Tagen Tausende fehlgeschlagener Login-Versuche in deinen Auth-Logs sehen. Das ist keine Paranoia — das ist Dienstag im öffentlichen Internet.
Firewall mit UFW#
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enableDas war's. Vier Regeln. Nur SSH und Web-Traffic kommen durch.
Fail2Ban#
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.localBearbeite /etc/fail2ban/jail.local:
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600sudo systemctl enable fail2ban
sudo systemctl start fail2banDrei fehlgeschlagene SSH-Versuche und du bist für eine Stunde gesperrt. Ich habe zugesehen, wie Fail2Ban an einem einzigen Tag Hunderte von IPs blockiert hat. Es funktioniert.
Unbeaufsichtigte Sicherheitsupdates#
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgradesDein Server wird jetzt automatisch Sicherheitspatches installieren. Eine Sache weniger, die man vergessen kann.
Node.js: Verwende NVM, nicht apt#
Das sehe ich in jedem Tutorial: sudo apt install nodejs. Tu das nicht.
Ubuntus Paketquellen liefern uralte Node.js-Versionen. Selbst das NodeSource-PPA hinkt hinterher. Und wenn du zwischen Node 20 und Node 22 für verschiedene Projekte wechseln musst, steckst du fest.
NVM löst das:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm alias default lts/*Jetzt überprüfen:
node -v # v22.x.x oder was auch immer LTS aktuell ist
npm -vDer nicht offensichtliche Tipp: Wenn du globale Pakete mit NVM installierst (wie PM2), sind sie an diese Node-Version gebunden. Wenn du mit nvm use die Version wechselst, verschwinden deine Globals. Setze deinen Standard und bleibe auf dem Server dabei:
nvm alias default 22Das hat mich genau einmal erwischt. Einmal war genug.
PM2: Der Process Manager, der sich bezahlt macht#
PM2 ist der Unterschied zwischen "deployed" und "produktionsreif." Er übernimmt Prozessverwaltung, Clustering, Log-Rotation, Auto-Restart bei Abstürzen und Startup-Scripts. Kostenlos.
Installieren und einrichten#
npm install -g pm2Die Ecosystem-Konfiguration#
Starte Apps nicht mit CLI-Flags. Verwende eine ecosystem.config.js-Datei. Sie ist versionskontrolliert, reproduzierbar und selbstdokumentierend.
// ecosystem.config.js
module.exports = {
apps: [
{
name: "akousa",
script: "node_modules/.bin/next",
args: "start -p 3002",
cwd: "/var/www/akousa.net",
instances: 2,
exec_mode: "cluster",
max_memory_restart: "500M",
env: {
NODE_ENV: "production",
PORT: 3002,
},
// Graceful Shutdown
kill_timeout: 5000,
listen_timeout: 10000,
wait_ready: false,
// Logging
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
error_file: "/var/log/pm2/akousa-error.log",
out_file: "/var/log/pm2/akousa-out.log",
merge_logs: true,
// Auto-Restart bei Fehler
autorestart: true,
max_restarts: 10,
min_uptime: "10s",
// Nicht in Produktion watchen
watch: false,
},
],
};Lass mich die wichtigen Entscheidungen erklären:
instances: 2 statt "max": Auf einem kleinen VPS mit 1-2 Kernen klingt "max" clever, aber es werden Prozesse erzeugt, die während der Builds um Ressourcen kämpfen. Zwei Instanzen geben dir Zero-Downtime-Reloads und lassen trotzdem Spielraum. Auf einer 4+-Kern-Maschine, klar, verwende "max".
exec_mode: "cluster": Das ist, was Zero-Downtime-Reloads ermöglicht. Ohne Cluster-Modus ist pm2 reload nur ein verkleideter Restart. Mit Cluster-Modus startet PM2 Instanzen nacheinander neu — deine App geht nie komplett offline.
max_memory_restart: "500M": Deine Next.js-App hat ein Memory Leak? PM2 startet sie neu, bevor sie deinen Server per OOM-Kill erlegt. Das hat mich schon mehr als einmal vor 2-Uhr-morgens-Alarmen bewahrt.
kill_timeout: 5000: Gibt deiner App 5 Sekunden, um laufende Requests abzuschließen, bevor PM2 sie zwangsbeendet. Der Standardwert (1600ms) ist zu aggressiv für Apps mit Datenbankverbindungen.
watch: false: Ich habe Leute gesehen, die watch: true in Produktion gelassen haben. PM2 startet die App dann jedes Mal neu, wenn sich eine Log-Datei ändert. Deine App gerät in eine Restart-Schleife. Tu das nicht.
Startup-Script#
Lass PM2 Neustarts überleben:
pm2 startup systemd
# Kopiere und führe den Befehl aus, den es ausgibt
pm2 saveDas generiert einen systemd-Service. Nach einem Server-Neustart kommt deine App automatisch zurück. Teste es — starte deinen Server neu und überprüfe es. Nimm es nicht einfach an.
Log-Rotation#
Logs werden irgendwann deine Festplatte auffressen. Installiere das Rotationsmodul:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true50MB Maximum pro Datei, 7 rotierte Dateien behalten, die alten komprimieren. Ohne das habe ich gesehen, wie /var/log eine 25GB-Festplatte innerhalb von drei Wochen bei einer mäßig frequentierten App gefüllt hat.
Nginx: Der Reverse Proxy, der mehr kann als du denkst#
"Warum nicht einfach Node.js direkt auf Port 80 freigeben?"
Weil Nginx Dinge übernimmt, für die Node.js keine Zyklen verschwenden sollte: SSL-Terminierung, statische Dateien ausliefern, Gzip-Komprimierung, Request-Buffering, Verbindungslimits und eleganten Umgang mit langsamen Clients. Es ist in C geschrieben und genau dafür gebaut.
Installieren#
sudo apt install nginx -yDie Konfiguration#
# /etc/nginx/sites-available/akousa.net
upstream node_app {
server 127.0.0.1:3002;
keepalive 64;
}
server {
listen 80;
listen [::]:80;
server_name akousa.net www.akousa.net;
# Alle HTTP-Anfragen auf HTTPS umleiten
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name akousa.net www.akousa.net;
# SSL (von Certbot verwaltet — diese Zeilen werden automatisch hinzugefügt)
ssl_certificate /etc/letsencrypt/live/akousa.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/akousa.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Sicherheitsheader
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Gzip-Komprimierung
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
application/wasm;
# Proxy-Einstellungen
location / {
proxy_pass http://node_app;
proxy_http_version 1.1;
# Header
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;
# WebSocket-Unterstützung (falls benötigt)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts — großzügig aber nicht unendlich
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffering — lass Nginx langsame Clients abfangen
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
}
# Next.js statische Assets — lass Nginx sie direkt ausliefern
location /_next/static/ {
alias /var/www/akousa.net/.next/static/;
expires 365d;
access_log off;
add_header Cache-Control "public, immutable";
}
# Öffentliche statische Dateien
location /static/ {
alias /var/www/akousa.net/public/static/;
expires 30d;
access_log off;
}
# Zugriff auf Punkt-Dateien blockieren
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}Aktivieren:
sudo ln -s /etc/nginx/sites-available/akousa.net /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxFühre immer nginx -t vor dem Neuladen aus. Ich habe einmal eine fehlerhafte Konfiguration gepusht und die Seite lahmgelegt, weil ich die Syntaxprüfung übersprungen habe. Die fünf Zeichen nginx -t hätten mir dreißig Minuten panisches Debugging erspart.
Dinge, die die meisten Tutorials in dieser Konfiguration übersehen:
upstream-Block mit keepalive 64: Nginx verwendet Verbindungen zu deinem Node.js-Backend wieder, anstatt für jeden Request eine neue TCP-Verbindung zu öffnen. Das ist unter Last relevant.
proxy_buffering on: Nginx liest die gesamte Antwort von Node.js in den Speicher und sendet sie dann mit der Geschwindigkeit an den Client, die der Client verarbeiten kann. Ohne das blockiert ein langsamer Client auf einer 3G-Verbindung deinen Node.js-Worker.
_next/static/ direkt ausliefern: Das sind gehashte, unveränderliche Assets. Lass Nginx sie von der Festplatte mit einem 365-Tage-Cache-Header ausliefern. Deine Node.js-Prozesse sollten keine Zeit damit verschwenden.
SSL in fünf Minuten#
Let's Encrypt hat SSL gelöst. Wenn du 2026 noch für Zertifikate bezahlst, hör auf damit.
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.netCertbot fragt nach deiner E-Mail, akzeptiert die Nutzungsbedingungen und modifiziert automatisch deine Nginx-Konfiguration, um die SSL-Direktiven einzufügen. Das war's.
Automatische Verlängerung überprüfen#
Certbot installiert einen systemd-Timer, der zweimal täglich prüft und Zertifikate innerhalb von 30 Tagen vor Ablauf erneuert:
sudo systemctl list-timers | grep certbotTeste, ob die Verlängerung funktioniert:
sudo certbot renew --dry-runWenn der Dry Run erfolgreich ist, wirst du nie wieder über SSL nachdenken müssen. Wenn er fehlschlägt, liegt es meist daran, dass Port 80 blockiert ist (prüfe deine UFW-Regeln) oder Nginx nicht läuft.
Eine Sache, die mich erwischt hat: Wenn du Nginx vor Certbot einrichtest, stelle sicher, dass dein Server-Block auf Port 80 lauscht, ohne die HTTPS-Umleitung. Certbot muss Port 80 für die HTTP-01-Challenge erreichen können. Nachdem Certbot erfolgreich gelaufen ist, dann füge die Umleitung hinzu.
Das Deploy-Script#
Dieses Script läuft jedes Mal, wenn ich in Produktion deploye. Keine CI/CD-Plattform, keine GitHub Actions. Nur SSH und Bash.
#!/bin/bash
# deploy.sh — Deployment mit nahezu null Downtime
set -euo pipefail
APP_DIR="/var/www/akousa.net"
APP_NAME="akousa"
LOG_FILE="/var/log/deploy.log"
HEALTH_URL="http://localhost:3002"
MAX_RETRIES=10
RETRY_INTERVAL=3
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
log "=== Deploy gestartet ==="
cd "$APP_DIR"
# Neuesten Code ziehen
log "Ziehe neueste Änderungen..."
git pull origin main 2>&1 | tee -a "$LOG_FILE"
# Abhängigkeiten installieren
log "Installiere Abhängigkeiten..."
npm install --legacy-peer-deps 2>&1 | tee -a "$LOG_FILE"
# Bauen
log "Baue Anwendung..."
rm -rf .next
npm run build 2>&1 | tee -a "$LOG_FILE"
if [ $? -ne 0 ]; then
log "FEHLER: Build fehlgeschlagen. Deploy wird abgebrochen."
exit 1
fi
# PM2 neu laden (Zero-Downtime im Cluster-Modus)
log "Lade PM2 neu..."
pm2 reload "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
pm2 save 2>&1 | tee -a "$LOG_FILE"
# Health Check mit Wiederholungsversuchen
log "Führe Health Check aus..."
for i in $(seq 1 $MAX_RETRIES); do
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
log "Health Check bestanden (HTTP $HTTP_CODE)"
log "=== Deploy erfolgreich abgeschlossen ==="
exit 0
fi
log "Health Check Versuch $i/$MAX_RETRIES (HTTP $HTTP_CODE). Neuer Versuch in ${RETRY_INTERVAL}s..."
sleep $RETRY_INTERVAL
done
log "FEHLER: Health Check nach $MAX_RETRIES Versuchen fehlgeschlagen"
log "Rollback auf vorherigen PM2-Zustand..."
pm2 restart "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
exit 1Ausführbar machen:
chmod +x deploy.shVon deinem lokalen Rechner deployen:
ssh root@your-server-ip "bash /var/www/akousa.net/deploy.sh"Zentrale Entscheidungen in diesem Script:
set -euo pipefail: Das Script bricht bei jedem Fehler sofort ab. Ohne das läuft ein fehlgeschlagenes npm install still weiter in den Build-Schritt, und du bekommst einen kryptischen Fehler, dessen Debugging 20 Minuten kostet.
rm -rf .next vor dem Build: Next.js hat einen Build-Cache, der gelegentlich veraltete Ausgaben produziert. Das hat mich einmal erwischt — eine Seite zeigte alten Inhalt, obwohl der Quellcode aktualisiert war. Das Build-Verzeichnis zu löschen fügt vielleicht 15 Sekunden zum Build hinzu, garantiert aber frische Ausgabe.
pm2 reload statt pm2 restart: Das ist der Zero-Downtime-Teil. Im Cluster-Modus führt reload einen rollenden Neustart durch — es fährt neue Instanzen mit dem aktualisierten Code hoch, wartet bis sie bereit sind, und fährt dann die alten elegant herunter. Zu keinem Zeitpunkt laufen null Instanzen.
Health Check mit Wiederholungsversuchen: Next.js braucht ein paar Sekunden zum Aufwärmen nach dem Neustart. Das Script wartet bis zu 30 Sekunden (10 Versuche x 3 Sekunden) und prüft, ob die App mit HTTP 200 antwortet. Wenn nicht, stimmt etwas nicht, und du musst es sofort wissen — nicht erst durch einen Nutzer erfahren.
Rollback bei Fehler: Wenn der Health Check nach allen Versuchen fehlschlägt, startet das Script PM2 neu (was den zuletzt gespeicherten Zustand lädt). Es ist kein perfektes Rollback, aber besser als den Server in einem kaputten Zustand zu lassen.
Wenn um 2 Uhr morgens etwas kaputtgeht#
Hier ist, was ich bei genau diesem Setup tatsächlich debuggt habe:
"Die Seite ist down"#
Erste Befehle:
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.logNeun von zehn Mal sagt dir pm2 logs sofort, was passiert ist. Eine fehlende Umgebungsvariable, eine fehlgeschlagene Datenbankverbindung oder eine unbehandelte Promise-Rejection.
"Speicher wächst ständig"#
pm2 monitDas gibt dir ein Live-Dashboard von CPU und Speicher pro Prozess. Wenn der Speicher stetig steigt, ohne sich einzupendeln, hast du ein Leak. Die max_memory_restart-Einstellung in deiner Ecosystem-Konfiguration ist dein Sicherheitsnetz — PM2 startet den Prozess neu, bevor er den Server per OOM-Kill erlegt.
Für tiefere Untersuchung:
pm2 describe akousaDas zeigt Uptime, Restart-Zähler und Speicher-Snapshots. Wenn du 47 Restarts in den letzten 24 Stunden siehst, ist das dein Hinweis.
"SSL-Zertifikat abgelaufen"#
sudo certbot certificatesListet alle Zertifikate mit ihrem Ablaufdatum auf. Wenn die automatische Verlängerung fehlgeschlagen ist:
sudo certbot renew --force-renewal
sudo systemctl reload nginx"Festplatte voll"#
df -h
du -sh /var/log/*
pm2 flushpm2 flush löscht sofort alle PM2-Logdateien. Wenn du die Log-Rotation nicht eingerichtet hast (ich hab's dir gesagt), ist das der Moment, wo du den Schmerz spürst.
Der Befehl, den ich jeden Morgen ausführe#
ssh deploy@akousa.net "pm2 status && df -h / && uptime"Drei Dinge in einer Zeile: Laufen meine Prozesse, ist meine Festplatte in Ordnung, ist der Server überlastet. Dauert zwei Sekunden. Fängt Probleme auf, bevor es die Nutzer tun.
Was die meisten Anleitungen dir nicht sagen#
Dein Build-Schritt ist deine größte Schwachstelle. Auf einem VPS mit 1GB RAM kann npm run build für eine Next.js-App 800MB+ Speicher verbrauchen. Wenn PM2 deine App während des Builds in zwei Instanzen ausführt, bekommst du einen OOM. Lösungen: Verwende eine Swap-Datei (mindestens 2GB), oder stoppe die App während der Builds und akzeptiere ein paar Sekunden Downtime. Ich verwende Swap.
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab--legacy-peer-deps in deinem Install-Befehl ist ein Code-Smell, keine Lösung. Ich verwende es, weil einige Pakete in meinem Abhängigkeitsbaum ihre Peer-Dependency-Bereiche nicht aktualisiert haben. Alle paar Monate versuche ich, es zu entfernen. Eines Tages wird es klappen. Bis dahin liefere ich aus.
Teste dein Deploy-Script von Grund auf. Klone dein Repository auf einem frischen Server und führe jeden Schritt manuell aus. Die Anzahl der "Works on my machine"-Probleme, die sich in Deploy-Scripts verstecken, ist peinlich. Ich habe drei Probleme in meinem gefunden, als ich das getan habe — fehlende globale Pakete, falsche Dateiberechtigungen und ein Pfad, der nur existierte, weil ich vorher etwas manuell eingerichtet hatte.
Trage die IP deines Servers in deine SSH-Konfiguration ein. Hör auf, IP-Adressen einzutippen:
# ~/.ssh/config
Host akousa
HostName 69.62.66.94
User deploy
IdentityFile ~/.ssh/id_ed25519Jetzt ist ssh akousa alles, was du brauchst. Kleine Dinge summieren sich.
Die vollständige Checkliste#
Bevor du es als fertig bezeichnest:
- Nicht-Root-Benutzer mit sudo-Zugang
- Nur SSH-Key-Authentifizierung, Passwort-Auth deaktiviert
- UFW aktiviert, nur notwendige Ports offen
- Fail2Ban schützt SSH
- Unbeaufsichtigte Sicherheitsupdates aktiviert
- Node.js über NVM installiert
- PM2 führt deine App im Cluster-Modus aus
- PM2-Startup-Script konfiguriert (überlebt Neustarts)
- PM2-Log-Rotation installiert
- Nginx-Reverse-Proxy mit korrekten Headern
- SSL über Let's Encrypt mit automatischer Verlängerung
- Deploy-Script mit Health Checks
- Swap-Datei konfiguriert (für Build-Spielraum)
- Getestet: Server neustarten und überprüfen, ob alles wieder hochkommt
Der letzte Punkt ist der, den die Leute überspringen. Sei nicht diese Person. Starte den Server neu, warte 60 Sekunden und prüfe, ob deine App live ist. Wenn nicht, sind deine Startup-Scripts falsch konfiguriert, und du wirst es zum denkbar schlechtesten Zeitpunkt herausfinden.
Ist das "Enterprise-Grade"?#
Nein. Und das ist der Punkt.
Dieses Setup liefert diesen Blog zuverlässig für unter 10$/Monat aus. Es wird in 30 Sekunden mit einem einzigen Befehl deployt. Ich verstehe jedes einzelne Teil davon. Wenn etwas kaputtgeht, weiß ich genau, wo ich schauen muss.
Könnte ich Docker verwenden? Klar. Könnte ich Kubernetes verwenden? Technisch gesehen. Könnte ich eine vollständige CI/CD-Pipeline mit Staging-Umgebungen und Canary-Deployments einrichten? Absolut.
Aber ich habe gelernt, dass die beste Infrastruktur die ist, die man wirklich versteht, um 2 Uhr morgens debuggen kann und die nicht mehr kostet, als das Projekt einbringt. Für eine persönliche Seite, ein SaaS-MVP oder ein kleines Startup — das ist dieses Setup.
Erst ausliefern. Skalieren, wenn es nötig wird. Und immer, immer, das Deploy-Script auf einem frischen Server testen.