GitHub Actions CI/CD: Zero-Downtime Deployments, die wirklich funktionieren
Mein komplettes GitHub Actions Setup: parallele Test-Jobs, Docker Build Caching, SSH-Deployment auf VPS, Zero-Downtime mit PM2 Reload, Secrets Management und die Workflow-Muster, die ich über zwei Jahre verfeinert habe.
Jedes Projekt, an dem ich gearbeitet habe, erreicht irgendwann denselben Wendepunkt: Der Deploy-Prozess wird zu schmerzhaft für manuelles Ausführen. Du vergisst, die Tests laufen zu lassen. Du baust lokal, aber vergisst die Version zu bumpen. Du gehst per SSH auf den Produktionsserver und merkst, dass die letzte Person, die deployt hat, eine veraltete .env-Datei hinterlassen hat.
GitHub Actions hat das vor zwei Jahren für mich gelöst. Nicht perfekt am ersten Tag — der erste Workflow, den ich geschrieben habe, war ein 200-Zeilen-YAML-Albtraum, der die Hälfte der Zeit timeouttete und nichts cachte. Aber Iteration für Iteration kam ich bei etwas an, das diese Seite zuverlässig deployt, mit Zero Downtime, in unter vier Minuten.
Das ist dieser Workflow, Abschnitt für Abschnitt erklärt. Nicht die Docs-Version. Die Version, die den Kontakt mit der Produktion überlebt.
Die Bausteine verstehen#
Bevor wir in die vollständige Pipeline einsteigen, brauchst du ein klares mentales Modell davon, wie GitHub Actions funktioniert. Wenn du Jenkins oder CircleCI benutzt hast, vergiss das meiste davon. Die Konzepte mappen lose, aber das Ausführungsmodell ist unterschiedlich genug, um dich ins Stolpern zu bringen.
Trigger: Wann dein Workflow läuft#
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1" # Jeden Montag um 6 Uhr UTC
workflow_dispatch:
inputs:
environment:
description: "Zielumgebung"
required: true
type: choice
options:
- staging
- productionpush und pull_request sind die Arbeitspferde. schedule ist für periodische Aufgaben — ich nutze es für wöchentliche Dependency-Updates und Sicherheitsscans. workflow_dispatch erlaubt manuelle Auslösung mit benutzerdefinierten Inputs über die GitHub-UI; das ist dein Notfall-Deploy-Button.
Jobs und Steps#
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lintJobs laufen standardmäßig parallel. Jeder Job bekommt eine frische VM (den "Runner"). ubuntu-latest gibt dir eine ordentlich ausgestattete Maschine — 4 vCPUs, 16 GB RAM Stand 2026. Für öffentliche Repos kostenlos, 2000 Minuten/Monat für private.
Steps laufen sequentiell innerhalb eines Jobs. Jeder uses:-Step zieht eine wiederverwendbare Action aus dem Marketplace. Jeder run:-Step führt einen Shell-Befehl aus.
Das --frozen-lockfile-Flag ist entscheidend. Ohne es könnte pnpm install deinen Lockfile während CI aktualisieren, was bedeutet, dass du nicht dieselben Dependencies testest, die dein Entwickler committed hat. Ich habe Phantom-Testfehler dadurch erlebt, die lokal verschwinden, weil der Lockfile auf der Maschine des Entwicklers bereits korrekt ist.
Umgebungsvariablen vs Secrets#
env:
NODE_ENV: production
NEXT_TELEMETRY_DISABLED: 1
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
echo "$SSH_PRIVATE_KEY" > key.pem
chmod 600 key.pem
ssh -i key.pem deploy@$DEPLOY_HOST "cd /var/www/app && ./deploy.sh"Umgebungsvariablen, die mit env: auf Workflow-Ebene gesetzt werden, sind Klartext, sichtbar in Logs. Verwende sie für nicht-sensible Konfiguration: NODE_ENV, Telemetrie-Flags, Feature-Toggles.
Secrets (${{ secrets.X }}) sind verschlüsselt gespeichert, in Logs maskiert und nur für Workflows im selben Repo verfügbar. Sie werden unter Settings > Secrets and variables > Actions gesetzt.
Die Zeile environment: production ist bedeutsam. GitHub Environments lassen dich Secrets auf bestimmte Deployment-Ziele einschränken. Dein Staging-SSH-Key und dein Produktions-SSH-Key können beide SSH_PRIVATE_KEY heißen, aber unterschiedliche Werte halten, abhängig davon, welche Umgebung der Job anvisiert. Das schaltet auch erforderliche Reviewer frei — du kannst Produktions-Deploys hinter einer manuellen Freigabe schützen.
Die vollständige CI-Pipeline#
So strukturiere ich die CI-Hälfte der Pipeline. Das Ziel: Jeden Fehlerkategorie in kürzestmöglicher Zeit abfangen.
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lint
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm tsc --noEmit
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm test -- --coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
retention-days: 7
build:
name: Build
needs: [lint, typecheck, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: .next/
retention-days: 1Warum diese Struktur#
Lint, Typecheck und Test laufen parallel. Sie haben keine Abhängigkeiten voneinander. Ein Typfehler blockiert nicht das Linting, und ein fehlgeschlagener Test muss nicht auf den Type Checker warten. In einem typischen Lauf sind alle drei in 30-60 Sekunden fertig, während sie gleichzeitig laufen.
Build wartet auf alle drei. Die Zeile needs: [lint, typecheck, test] bedeutet, dass der Build-Job nur startet, wenn Lint, Typecheck UND Test alle bestehen. Es hat keinen Sinn, ein Projekt zu bauen, das Lint-Fehler oder Typfehler hat.
concurrency mit cancel-in-progress: true spart enorm Zeit. Wenn du zwei Commits in schneller Folge pushst, wird der erste CI-Lauf abgebrochen. Ohne das hast du veraltete Läufe, die dein Minutenbudget auffressen und die Checks-UI vollstopfen.
Coverage-Upload mit if: always() bedeutet, du bekommst den Coverage-Report auch wenn Tests fehlschlagen. Das ist nützlich zum Debuggen — du siehst, welche Tests fehlgeschlagen sind und was sie abgedeckt haben.
Fail-Fast vs. Alle laufen lassen#
Standardmäßig bricht GitHub die anderen ab, wenn ein Job in einer Matrix fehlschlägt. Für CI will ich dieses Verhalten — wenn Lint fehlschlägt, interessieren mich die Testergebnisse nicht. Erst das Linting fixen.
Aber für Test-Matrizen (sagen wir, Testen über Node 20 und Node 22), willst du vielleicht alle Fehler auf einmal sehen:
test:
strategy:
fail-fast: false
matrix:
node-version: [20, 22]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm testfail-fast: false lässt beide Matrix-Beine zu Ende laufen. Wenn Node 22 fehlschlägt aber Node 20 besteht, siehst du diese Information sofort, statt neu starten zu müssen.
Caching für Geschwindigkeit#
Die einzelne größte Verbesserung, die du an der CI-Geschwindigkeit machen kannst, ist Caching. Ein kaltes pnpm install auf einem mittleren Projekt dauert 30-45 Sekunden. Mit warmem Cache sind es 3-5 Sekunden. Multipliziere das über vier parallele Jobs und du sparst zwei Minuten bei jedem Lauf.
pnpm Store Cache#
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"Dieser Einzeiler cacht den pnpm Store (~/.local/share/pnpm/store). Bei Cache-Hit verlinkt pnpm install --frozen-lockfile einfach hart vom Store statt herunterzuladen. Allein das schneidet die Installationszeit bei wiederholten Läufen um 80%.
Wenn du mehr Kontrolle brauchst — sagen wir, du willst auch basierend auf dem OS cachen — verwende actions/cache direkt:
- uses: actions/cache@v4
with:
path: |
~/.local/share/pnpm/store
node_modules
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-${{ runner.os }}-Der restore-keys-Fallback ist wichtig. Wenn sich pnpm-lock.yaml ändert (neue Dependency), matcht der exakte Key nicht, aber der Prefix-Match stellt trotzdem die meisten gecachten Packages wieder her. Nur die Differenz wird heruntergeladen.
Next.js Build Cache#
Next.js hat seinen eigenen Build-Cache in .next/cache. Diesen zwischen Läufen zu cachen bedeutet inkrementelle Builds — nur geänderte Seiten und Komponenten werden neu kompiliert.
- uses: actions/cache@v4
with:
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
nextjs-${{ runner.os }}-Diese dreistufige Key-Strategie bedeutet:
- Exakter Match: Gleiche Dependencies UND gleiche Quelldateien. Voller Cache-Hit, Build ist nahezu instant.
- Partieller Match (Dependencies): Dependencies gleich aber Quellcode geändert. Build kompiliert nur geänderte Dateien neu.
- Partieller Match (nur OS): Dependencies geändert. Build nutzt wieder, was möglich ist.
Echte Zahlen aus meinem Projekt: Kalter Build dauert ~55 Sekunden, gecachter Build ~15 Sekunden. Das ist eine 73%ige Reduktion.
Docker Layer Caching#
Docker Builds sind dort, wo Caching richtig wirkungsvoll wird. Ein vollständiger Next.js Docker Build — OS-Dependencies installieren, Quellcode kopieren, pnpm install laufen lassen, next build laufen lassen — dauert 3-4 Minuten kalt. Mit Layer Caching sind es 30-60 Sekunden.
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=maxtype=gha nutzt das eingebaute Cache-Backend von GitHub Actions. mode=max cacht alle Layer, nicht nur die finalen. Das ist kritisch für Multi-Stage Builds, bei denen Zwischenlayer (wie pnpm install) am teuersten zum Neubauen sind.
Turborepo Remote Cache#
Wenn du in einem Monorepo mit Turborepo bist, ist Remote Caching transformativ. Der erste Build lädt Task-Outputs in den Cache hoch. Nachfolgende Builds laden herunter statt neu zu berechnen.
- run: pnpm turbo build --remote-only
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}Ich habe Monorepo CI-Zeiten von 8 Minuten auf 90 Sekunden fallen sehen mit Turbo Remote Cache. Der Haken: Es erfordert einen Vercel-Account oder selbstgehosteten Turbo-Server. Für Single-App-Repos ist es Overkill.
Docker Build und Push#
Wenn du auf einen VPS (oder irgendeinen Server) deployst, gibt dir Docker reproduzierbare Builds. Das gleiche Image, das in CI läuft, ist das gleiche Image, das in Produktion läuft. Kein "Es funktioniert auf meinem Rechner" mehr, weil der Rechner das Image ist.
Multi-Stage Dockerfile#
Bevor wir zum Workflow kommen, hier das Dockerfile, das ich für Next.js verwende:
# Stage 1: Dependencies
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
# Stage 2: Build
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# Stage 3: Production
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]Drei Stages, klare Trennung. Das finale Image ist ~150MB statt der ~1,2GB, die du bekommen würdest, wenn du alles kopierst. Nur Produktionsartefakte schaffen es in die Runner-Stage.
Der Build-und-Push-Workflow#
name: Build and Push Docker Image
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=maxLass mich die wichtigen Entscheidungen hier erklären.
GitHub Container Registry (ghcr.io)#
Ich verwende ghcr.io statt Docker Hub aus drei Gründen:
- Authentifizierung ist kostenlos.
GITHUB_TOKENist automatisch in jedem Workflow verfügbar — keine Docker Hub Credentials nötig. - Nähe. Images werden von derselben Infrastruktur gezogen, auf der dein CI läuft. Pulls während CI sind schnell.
- Sichtbarkeit. Images sind mit deinem Repo in der GitHub-UI verknüpft. Du siehst sie im Packages-Tab.
Multi-Platform Builds#
platforms: linux/amd64,linux/arm64Diese Zeile fügt vielleicht 90 Sekunden zu deinem Build hinzu, aber sie lohnt sich. ARM64-Images laufen nativ auf:
- Apple Silicon Macs (M1/M2/M3/M4) bei der lokalen Entwicklung mit Docker Desktop
- AWS Graviton Instanzen (20-40% günstiger als x86 Äquivalente)
- Oracles kostenlosem ARM-Tier
Ohne das laufen deine Entwickler auf M-Series Macs x86-Images durch Rosetta-Emulation. Es funktioniert, aber es ist spürbar langsamer und fördert gelegentlich seltsame architekturspezifische Bugs zutage.
QEMU liefert die Cross-Kompilierungsschicht. Buildx orchestriert den Multi-Arch-Build und pusht eine Manifest-Liste, sodass Docker automatisch die richtige Architektur zieht.
Tagging-Strategie#
tags: |
type=sha,prefix=
type=ref,event=branch
type=raw,value=latest,enable={{is_default_branch}}Jedes Image bekommt drei Tags:
abc1234(Commit SHA): Unveränderlich. Du kannst immer einen exakten Commit deployen.main(Branch-Name): Veränderlich. Zeigt auf den letzten Build von diesem Branch.latest: Veränderlich. Nur auf dem Default-Branch gesetzt. Das ist, was dein Server zieht.
Deploye latest niemals in Produktion, ohne auch den SHA irgendwo festzuhalten. Wenn etwas kaputtgeht, musst du wissen, welches latest. Ich speichere den deployten SHA in einer Datei auf dem Server, die der Health-Endpunkt liest.
SSH-Deployment auf VPS#
Hier kommt alles zusammen. CI besteht, Docker-Image ist gebaut und gepusht, jetzt müssen wir dem Server sagen, das neue Image zu ziehen und neuzustarten.
Die SSH Action#
deploy:
name: Deploy to Production
needs: [build-and-push]
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script_stop: true
script: |
set -euo pipefail
APP_DIR="/var/www/akousa.net"
IMAGE="ghcr.io/${{ github.repository }}:latest"
DEPLOY_SHA="${{ github.sha }}"
echo "=== Deploying $DEPLOY_SHA ==="
# Neuestes Image ziehen
docker pull "$IMAGE"
# Alten Container stoppen und entfernen
docker stop akousa-app || true
docker rm akousa-app || true
# Neuen Container starten
docker run -d \
--name akousa-app \
--restart unless-stopped \
--network host \
-e NODE_ENV=production \
-e DATABASE_URL="${DATABASE_URL}" \
-p 3000:3000 \
"$IMAGE"
# Auf Health Check warten
echo "Waiting for health check..."
for i in $(seq 1 30); do
if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
echo "Health check passed on attempt $i"
break
fi
if [ "$i" -eq 30 ]; then
echo "Health check failed after 30 attempts"
exit 1
fi
sleep 2
done
# Deployten SHA aufzeichnen
echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
# Alte Images aufräumen
docker image prune -af --filter "until=168h"
echo "=== Deploy complete ==="Die Deploy-Script-Alternative#
Für alles über ein einfaches Pull-und-Restart hinaus verschiebe ich die Logik in ein Script auf dem Server statt sie im Workflow inline zu haben:
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
log "Starting deployment..."
# Bei GHCR einloggen
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
# Pull mit Retry
for attempt in 1 2 3; do
if docker pull "$IMAGE"; then
log "Image pulled successfully on attempt $attempt"
break
fi
if [ "$attempt" -eq 3 ]; then
log "ERROR: Failed to pull image after 3 attempts"
exit 1
fi
log "Pull attempt $attempt failed, retrying in 5s..."
sleep 5
done
# Health-Check-Funktion
health_check() {
local port=$1
local max_attempts=30
for i in $(seq 1 $max_attempts); do
if curl -sf "http://localhost:$port/api/health" > /dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
# Neuen Container auf alternativem Port starten
docker run -d \
--name akousa-app-new \
--env-file "$APP_DIR/.env.production" \
-p 3001:3000 \
"$IMAGE"
# Neuen Container auf Gesundheit prüfen
if ! health_check 3001; then
log "ERROR: New container failed health check. Rolling back."
docker stop akousa-app-new || true
docker rm akousa-app-new || true
exit 1
fi
log "New container healthy. Switching traffic..."
# Nginx Upstream umschalten
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
# Alten Container stoppen
docker stop akousa-app || true
docker rm akousa-app || true
# Neuen Container umbenennen
docker rename akousa-app-new akousa-app
log "Deployment complete."Der Workflow wird dann ein einzelner SSH-Befehl:
script: |
cd /var/www/akousa.net && ./deploy.shDas ist besser weil: (1) die Deploy-Logik auf dem Server versioniert ist, (2) du es manuell per SSH zum Debuggen ausführen kannst, und (3) du kein YAML in YAML in Bash escapen musst.
Zero-Downtime-Strategien#
"Zero Downtime" klingt nach Marketing-Sprech, hat aber eine präzise Bedeutung: Kein Request bekommt ein Connection Refused oder einen 502 während des Deployments. Hier sind drei echte Ansätze, von einfachsten zum robustesten.
Strategie 1: PM2 Cluster Mode Reload#
Wenn du Node.js direkt ausführst (nicht in Docker), gibt dir PM2s Cluster-Modus den einfachsten Zero-Downtime-Pfad.
# ecosystem.config.js hat bereits:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 reload (nicht restart) macht einen Rolling Restart. Es startet neue Worker, wartet bis sie bereit sind, dann beendet es alte Worker einzeln. Zu keinem Zeitpunkt bedienen null Worker Traffic.
Das --update-env-Flag lädt Umgebungsvariablen aus der Ecosystem-Config neu. Ohne es bleibt dein altes env bestehen, selbst nach einem Deploy, der .env geändert hat.
In deinem Workflow:
- name: Deploy and reload PM2
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/akousa.net
git pull origin main
pnpm install --frozen-lockfile
pnpm build
pm2 reload ecosystem.config.js --update-envDas ist, was ich für diese Seite verwende. Es ist einfach, zuverlässig, und die Downtime ist buchstäblich null — ich habe es mit einem Lastgenerator getestet, der 100 req/s während Deploys feuert. Kein einziger 5xx.
Strategie 2: Blue/Green mit Nginx Upstream#
Für Docker-Deployments gibt dir Blue/Green eine saubere Trennung zwischen alter und neuer Version.
Das Konzept: Den alten Container ("Blue") auf Port 3000 und den neuen Container ("Green") auf Port 3001 laufen lassen. Nginx zeigt auf Blue. Du startest Green, prüfst seine Gesundheit, schaltest Nginx auf Green um, dann stoppst du Blue.
Nginx Upstream-Config:
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
server 127.0.0.1:3000;
}# /etc/nginx/sites-available/akousa.net
server {
listen 443 ssl http2;
server_name akousa.net;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
}
}Das Umschaltskript:
#!/bin/bash
set -euo pipefail
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
if [ "$CURRENT_PORT" = "3000" ]; then
NEW_PORT=3001
OLD_PORT=3000
else
NEW_PORT=3000
OLD_PORT=3001
fi
echo "Current: $OLD_PORT -> New: $NEW_PORT"
# Neuen Container auf alternativem Port starten
docker run -d \
--name "akousa-app-$NEW_PORT" \
--env-file /var/www/akousa.net/.env.production \
-p "$NEW_PORT:3000" \
"ghcr.io/akousa/akousa-net:latest"
# Auf Gesundheit warten
for i in $(seq 1 30); do
if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
echo "New container healthy on port $NEW_PORT"
break
fi
[ "$i" -eq 30 ] && { echo "Health check failed"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
sleep 2
done
# Nginx umschalten
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
# Alten Container stoppen
sleep 5 # In-Flight-Requests abschließen lassen
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
echo "Switched from :$OLD_PORT to :$NEW_PORT"Der 5-Sekunden-Sleep nach dem Nginx-Reload ist keine Faulheit — es ist Grace Time. Nginx' Reload ist graceful (bestehende Verbindungen bleiben offen), aber manche Long-Polling-Verbindungen oder Streaming-Responses brauchen Zeit zum Abschließen.
Strategie 3: Docker Compose mit Health Checks#
Für einen strukturierteren Ansatz kann Docker Compose den Blue/Green-Wechsel managen:
# docker-compose.yml
services:
app:
image: ghcr.io/akousa/akousa-net:latest
restart: unless-stopped
env_file: .env.production
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
order: start-first
failure_action: rollback
rollback_config:
parallelism: 0
order: stop-first
ports:
- "3000:3000"Das order: start-first ist die Schlüsselzeile. Es bedeutet "starte den neuen Container, bevor der alte gestoppt wird." Kombiniert mit parallelism: 1 bekommst du ein Rolling Update — ein Container nach dem anderen, immer mit erhaltener Kapazität.
Deploye mit:
docker compose pull
docker compose up -d --remove-orphansDocker Compose überwacht den Healthcheck und routet keinen Traffic zum neuen Container, bis er besteht. Wenn der Healthcheck fehlschlägt, revertet failure_action: rollback automatisch zur vorherigen Version. Das kommt Kubernetes-artigen Rolling Deployments so nahe wie möglich auf einem einzelnen VPS.
Secrets Management#
Secrets Management ist eines dieser Dinge, die man leicht "fast richtig" und katastrophal falsch in den verbleibenden Randfällen hinbekommt.
GitHub Secrets: Die Grundlagen#
# Gesetzt über GitHub UI: Settings > Secrets and variables > Actions
steps:
- name: Use a secret
env:
DB_URL: ${{ secrets.DATABASE_URL }}
run: |
# Der Wert wird in Logs maskiert
echo "Connecting to database..."
# Das würde "Connecting to ***" in den Logs ausgeben
echo "Connecting to $DB_URL"GitHub redaktiert automatisch Secret-Werte aus der Log-Ausgabe. Wenn dein Secret p@ssw0rd123 ist und irgendein Step diesen String ausgibt, zeigen die Logs ***. Das funktioniert gut, mit einem Vorbehalt: Wenn dein Secret kurz ist (wie eine 4-stellige PIN), maskiert GitHub es möglicherweise nicht, weil es auf harmlose Strings matchen könnte. Halte Secrets ausreichend komplex.
Environment-Scoped Secrets#
jobs:
deploy-staging:
environment: staging
steps:
- run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
# DEPLOY_HOST = staging.akousa.net
deploy-production:
environment: production
steps:
- run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
# DEPLOY_HOST = akousa.netGleicher Secret-Name, verschiedene Werte pro Umgebung. Das environment-Feld auf dem Job bestimmt, welcher Satz von Secrets injiziert wird.
Produktionsumgebungen sollten erforderliche Reviewer aktiviert haben. Das bedeutet, ein Push auf main triggert den Workflow, CI läuft automatisch, aber der Deploy-Job pausiert und wartet darauf, dass jemand "Approve" in der GitHub-UI klickt. Für ein Solo-Projekt fühlt sich das wie Overhead an. Für alles mit Benutzern ist es ein Lebensretter beim ersten Mal, wenn du versehentlich etwas Kaputtes mergst.
OIDC: Keine statischen Credentials mehr#
Statische Credentials (AWS Access Keys, GCP Service Account JSON-Dateien), die in GitHub Secrets gespeichert sind, sind eine Belastung. Sie laufen nicht ab, sie können nicht auf einen bestimmten Workflow-Lauf beschränkt werden, und wenn sie lecken, musst du sie manuell rotieren.
OIDC (OpenID Connect) löst das. GitHub Actions agiert als Identity Provider, und dein Cloud-Provider vertraut ihm, kurzlebige Credentials on the fly auszustellen:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # Erforderlich für OIDC
contents: read
steps:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: eu-central-1
- name: Push to ECR
run: |
aws ecr get-login-password --region eu-central-1 | \
docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.comKein Access Key. Kein Secret Key. Die configure-aws-credentials-Action fordert ein temporäres Token von AWS STS an, unter Verwendung von GitHubs OIDC-Token. Der Token ist auf das spezifische Repo, den Branch und die Umgebung begrenzt. Er läuft nach dem Workflow-Lauf ab.
GCP hat ein äquivalentes Setup mit Workload Identity Federation. Azure hat federated Credentials. Wenn dein Cloud-Provider OIDC unterstützt, nutze es. Es gibt keinen Grund, 2026 noch statische Cloud-Credentials zu speichern.
Deployment SSH Keys#
Für VPS-Deployments über SSH generiere ein dediziertes Schlüsselpaar:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""Füge den öffentlichen Schlüssel zur ~/.ssh/authorized_keys des Servers mit Einschränkungen hinzu:
restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy
Das restrict-Prefix deaktiviert Port Forwarding, Agent Forwarding, PTY-Allokation und X11 Forwarding. Das command=-Prefix bedeutet, dieser Schlüssel kann nur das Deploy-Script ausführen. Selbst wenn der private Schlüssel kompromittiert wird, kann der Angreifer dein Deploy-Script ausführen und sonst nichts.
Füge den privaten Schlüssel zu GitHub Secrets als SSH_PRIVATE_KEY hinzu. Das ist das eine statische Credential, das ich akzeptiere — SSH-Keys mit Forced Commands haben einen sehr begrenzten Blast Radius.
PR-Workflows: Preview Deployments#
Jeder PR verdient eine Preview-Umgebung. Sie fängt visuelle Bugs, die Unit-Tests übersehen, lässt Designer ohne Code-Checkout reviewen und macht das Leben der QA dramatisch einfacher.
Preview bei PR Open deployen#
name: Preview Deploy
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
preview:
runs-on: ubuntu-latest
environment:
name: preview-${{ github.event.number }}
url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@v4
- name: Build preview image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Deploy preview
id: deploy
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PREVIEW_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
PR_NUM=${{ github.event.number }}
PORT=$((4000 + PR_NUM))
IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
docker pull "$IMAGE"
docker stop "preview-${PR_NUM}" || true
docker rm "preview-${PR_NUM}" || true
docker run -d \
--name "preview-${PR_NUM}" \
--restart unless-stopped \
-e NODE_ENV=preview \
-p "${PORT}:3000" \
"$IMAGE"
echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
- name: Comment PR with preview URL
uses: actions/github-script@v7
with:
script: |
const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
const body = `### Preview Deployment
| Status | URL |
|--------|-----|
| :white_check_mark: Deployed | [${url}](${url}) |
_Last updated: ${new Date().toISOString()}_
_Commit: \`${{ github.sha }}\`_`;
// Bestehenden Kommentar finden
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.data.find(c =>
c.user.type === 'Bot' && c.body.includes('Preview Deployment')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}Die Port-Berechnung (4000 + PR_NUM) ist ein pragmatischer Hack. PR #42 bekommt Port 4042. Solange du nicht mehr als ein paar hundert offene PRs hast, gibt es keine Kollisionen. Eine Nginx-Wildcard-Config routet pr-*.preview.akousa.net zum richtigen Port.
Aufräumen bei PR Close#
Preview-Umgebungen, die nicht aufgeräumt werden, fressen Festplatte und Speicher. Füge einen Cleanup-Job hinzu:
name: Cleanup Preview
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Remove preview container
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PREVIEW_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
PR_NUM=${{ github.event.number }}
docker stop "preview-${PR_NUM}" || true
docker rm "preview-${PR_NUM}" || true
docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
echo "Preview for PR #${PR_NUM} cleaned up."
- name: Deactivate environment
uses: actions/github-script@v7
with:
script: |
const deployments = await github.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: `preview-${{ github.event.number }}`,
});
for (const deployment of deployments.data) {
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.id,
state: 'inactive',
});
}Required Status Checks#
In deinen Repository-Einstellungen (Settings > Branches > Branch protection rules) fordere diese Checks vor dem Mergen:
lint— Keine Lint-Fehlertypecheck— Keine Typfehlertest— Alle Tests bestehenbuild— Projekt baut erfolgreich
Ohne das wird jemand einen PR mit fehlgeschlagenen Checks mergen. Nicht böswillig — sie sehen "2 von 4 Checks bestanden" und nehmen an, die anderen beiden laufen noch. Schließ es ab.
Aktiviere auch "Require branches to be up to date before merging." Das erzwingt einen erneuten CI-Lauf nach dem Rebase auf den neuesten main. Es fängt den Fall ab, dass zwei PRs einzeln CI bestehen, aber im Zusammenspiel konfligieren.
Benachrichtigungen#
Ein Deployment, von dem niemand weiß, ist ein Deployment, dem niemand vertraut. Benachrichtigungen schließen die Feedback-Schleife.
Slack Webhook#
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Repository:*\n${{ github.repository }}"
},
{
"type": "mrkdwn",
"text": "*Branch:*\n${{ github.ref_name }}"
},
{
"type": "mrkdwn",
"text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
},
{
"type": "mrkdwn",
"text": "*Triggered by:*\n${{ github.actor }}"
}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "View Run"
},
"url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
]
}
]
}Das if: always() ist kritisch. Ohne es wird der Benachrichtigungsschritt übersprungen, wenn der Deploy fehlschlägt — was genau der Zeitpunkt ist, an dem du ihn am meisten brauchst.
Failure-Only E-Mail#
Für kritische Deployments löse ich auch eine E-Mail bei Fehlschlag aus. Nicht über GitHubs eingebaute E-Mail (zu laut), sondern über einen gezielten Webhook:
- name: Alert on failure
if: failure()
run: |
curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
-H "Content-Type: application/json" \
-d '{
"subject": "DEPLOY FAILED: ${{ github.repository }}",
"body": "Commit: ${{ github.sha }}\nActor: ${{ github.actor }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}'Das ist meine letzte Verteidigungslinie. Slack ist großartig, aber auch laut — Leute stummschalten Channels. Eine "DEPLOY FAILED"-E-Mail mit Link zum Lauf bekommt Aufmerksamkeit.
Lektionen aus zwei Jahren Iteration#
Ich schließe mit den Fehlern, die ich gemacht habe, damit du sie nicht machen musst.
Pinne deine Action-Versionen. uses: actions/checkout@v4 ist in Ordnung, aber für Produktion erwäge uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (der volle SHA). Eine kompromittierte Action könnte deine Secrets exfiltrieren. Der tj-actions/changed-files-Vorfall 2025 hat bewiesen, dass das nicht theoretisch ist.
Cache nicht alles. Ich habe einmal node_modules direkt gecacht (nicht nur den pnpm Store) und zwei Stunden damit verbracht, einen Phantom-Build-Fehler zu debuggen, der durch veraltete native Bindings verursacht wurde. Cache den Package-Manager-Store, nicht die installierten Module.
Setze Timeouts. Jeder Job sollte timeout-minutes haben. Der Standard sind 360 Minuten (6 Stunden). Wenn dein Deploy hängt, weil die SSH-Verbindung abgebrochen ist, willst du das nicht sechs Stunden später entdecken, wenn du dein monatliches Minutenkontingent aufgebraucht hast.
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestNutze concurrency klug. Für PRs ist cancel-in-progress: true immer richtig — niemanden interessiert das CI-Ergebnis eines Commits, über den bereits force-gepusht wurde. Für Produktions-Deploys setze es auf false. Du willst nicht, dass ein schnell folgender Commit einen Deploy abbricht, der mitten im Rollout ist.
Teste deine Workflow-Datei. Nutze act (https://github.com/nektos/act), um Workflows lokal auszuführen. Es fängt nicht alles ab (Secrets sind nicht verfügbar und die Runner-Umgebung unterscheidet sich), aber es fängt YAML-Syntaxfehler und offensichtliche Logikbugs, bevor du pushst.
Überwache deine CI-Kosten. GitHub Actions Minuten sind kostenlos für öffentliche Repos und günstig für private, aber sie summieren sich. Multi-Platform Docker Builds verbrauchen 2x die Minuten (eine pro Plattform). Matrix-Test-Strategien multiplizieren deine Laufzeit. Behalte die Abrechnungsseite im Blick.
Die beste CI/CD-Pipeline ist die, der du vertraust. Vertrauen kommt von Zuverlässigkeit, Beobachtbarkeit und schrittweiser Verbesserung. Starte mit einer einfachen Lint-Test-Build-Pipeline. Füge Docker hinzu, wenn du Reproduzierbarkeit brauchst. Füge SSH-Deployment hinzu, wenn du Automatisierung brauchst. Füge Benachrichtigungen hinzu, wenn du Vertrauen brauchst. Baue nicht die volle Pipeline am ersten Tag — du wirst die Abstraktionen falsch haben.
Baue die Pipeline, die du heute brauchst, und lass sie mit deinem Projekt wachsen.