GitHub Actions CI/CD: Zero-Downtime-Deployment Guide

Automatisches Deployment mit GitHub Actions einrichten: CI/CD-Pipeline für Zero-Downtime-Deployments auf deinem VPS, Schritt für Schritt erklärt.

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.