GitHub Actions CI/CD: Zero-Downtime-Deployment Guide
Mein Portfolio läuft auf einem 5-Euro-VPS. Wenn ich was deploye, will ich nicht 30 Sekunden lang einen 502er ausliefern. Hier zeige ich, wie ich Zero-Downtime-Deployment für Next.js-Apps mit GitHub Actions umgesetzt habe — ohne Kubernetes, ohne Load-Balancer, ohne große Infrastruktur.
Disclaimer: "Zero Downtime" ist immer eine Definitionsfrage. Was hier steht, ist Downtime im Bereich von wenigen Hundert Millisekunden, was für die meisten Apps als zero gilt. Wer wirklich Null Downtime braucht (Banken, Trading-Systeme), liest hier den falschen Artikel.
Die naive Variante, die fast jeder kennt
- name: Deploy
run: |
ssh server "cd /app && git pull && npm install && npm run build && systemctl restart app"
Funktioniert, aber zwischen systemctl restart und dem ersten erfolgreichen Request der neuen Instanz vergehen oft mehrere Sekunden. In der Zeit kriegt jeder Request entweder ein 502 oder hängt im Connection-Timeout.
Das Problem: die alte Instanz ist tot, bevor die neue lebt.
Erster Schritt: atomarer Symlink-Swap
Idee: das Build-Output liegt in /app/releases/<commit-sha>/. Der "aktive" Release ist ein Symlink /app/current → /app/releases/<sha>. Beim Deploy wird ein neuer Release-Pfad gebaut, dann der Symlink atomar umgehängt, dann der Service neu gestartet.
RELEASE=/app/releases/$(git rev-parse HEAD)
mkdir -p "$RELEASE"
git -C "$RELEASE" clone --branch main /repo .
(cd "$RELEASE" && npm ci && npm run build)
ln -sfn "$RELEASE" /app/current.new
mv -Tf /app/current.new /app/current
systemctl restart app
mv -T ist atomar — der Symlink-Wechsel ist eine einzelne Filesystem-Operation, kein "lösche, dann schreibe". Das ist die Voraussetzung für die nächste Stufe.
Wirklich Zero-Downtime mit zwei Instanzen
Atomic-Symlink + Restart ist immer noch ein kurzer Restart. Für echte Zero-Downtime: zwei Service-Instanzen auf unterschiedlichen Ports, nginx-Upstream wechselt zwischen ihnen.
upstream app {
server 127.0.0.1:3001;
}
Deploy-Script:
ACTIVE=$(curl -sf http://127.0.0.1:3001/health > /dev/null && echo 3001 || echo 3002)
NEW=$([ "$ACTIVE" = "3001" ] && echo 3002 || echo 3001)
systemctl start app@$NEW
until curl -sf http://127.0.0.1:$NEW/health > /dev/null; do sleep 1; done
sed -i "s/127.0.0.1:$ACTIVE/127.0.0.1:$NEW/" /etc/nginx/sites-available/app
nginx -s reload
systemctl stop app@$ACTIVE
Voraussetzung: ein systemd-Template [email protected] mit EnvironmentFile=/etc/app/%i.env o.ä., damit die zwei Instanzen unterschiedliche Ports bedienen.
Der GitHub Actions Workflow
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ secrets.SERVER }} >> ~/.ssh/known_hosts
- name: Deploy
run: |
ssh deploy@${{ secrets.SERVER }} \
"/app/scripts/deploy.sh ${{ github.sha }}"
Drei Sachen, die hier wichtig sind:
Eigener Deploy-User, nicht root. Mit eingeschränkten sudo-Rechten nur für die spezifischen Service-Operationen.
Der Deploy-Key als GitHub Secret, nicht im Repo. Rotated alle paar Monate.
ssh-keyscan statt blindem Akzeptieren — sonst Man-in-the-Middle möglich.
Die häufigste Falle: Health Checks
Wenn die App lange braucht, bis sie auf Requests antwortet (DB-Migration beim Start, Cache warmup), muss der Health-Check-Endpoint das berücksichtigen. Sonst schaltet das Script auf eine Instanz um, die noch nicht ready ist, und die ersten paar Sekunden Traffic gehen verloren.
Ich teile bei wichtigen Apps /health (kommt sofort, prüft nur den Prozess) und /ready (braucht länger, prüft DB-Verbindung, Migrationen, etc.). Der Switch wartet auf /ready, das Monitoring auf /health.
Wann sich der Aufwand lohnt
Nicht für jedes Projekt. Ein internes Tool, das zweimal im Monat deployed wird? Standard-Restart reicht. Aber für eine Public-Facing-App, die jeden Push live geht, wird Zero-Downtime zur Pflicht. Der Aufwand ist drei Bash-Scripts und ein systemd-Template — überschaubar im Vergleich zur Alternative, ständig 502er für eine handvoll Sekunden auszuliefern.