Die Pipeline unter der Haube
Im letzten Beitrag stand die Pipeline nur als Randnotiz: Push auf main, gut vierzig Sekunden später ist der Beitrag live. Hier geht es um das, was in diesen Sekunden passiert — und vor allem darum, warum es so und nicht anders gebaut ist.
Das Ziel war von Anfang an eng gefasst: Ein git push soll die Seite aktualisieren, ohne dass ich mich auf dem Server einlogge. Kein SSH, kein docker compose von Hand, kein Ordner, den ich hochschiebe.
Warum ein Image und kein Datei-Sync
Die naheliegende Lösung wäre gewesen, den gebauten dist/-Ordner per rsync oder scp auf den Server zu kopieren. Weniger bewegliche Teile, schneller eingerichtet. Ich habe mich dagegen entschieden, aus drei Gründen.
Ein Datei-Sync ist nicht atomar. Während rsync läuft, liegen auf dem Server alte und neue Dateien nebeneinander. Bei einer statischen Seite mit gehashten Asset-Namen ist das meist harmlos, aber eben nur meist — ein HTML-Dokument, das auf ein noch nicht übertragenes Stylesheet zeigt, ist genau der Fehler, der sich nicht reproduzieren lässt.
Ein Datei-Sync hat kein Gedächtnis. Die alten Dateien sind nach dem Kopieren weg. Ein Rollback bedeutet dann: lokal den alten Stand auschecken, neu bauen, erneut hochladen. Ein Image mit Commit-SHA im Tag ist dagegen ein benanntes, unveränderliches Artefakt. Zurück auf den Stand von vorgestern heisst: einen Tag ändern, Container neu erstellen.
Und es passt nicht zum Rest. Für die später geplanten Anwendungen — eine Python-App, eine Spring-Boot-App — führt ohnehin kein Weg an Images vorbei. Zwei verschiedene Deployment-Verfahren auf demselben Server zu pflegen, wäre die schlechtere Variante gewesen, nur um bei der einfachsten Anwendung ein paar Zeilen zu sparen.
Das Dockerfile: zwei Stufen
# Stufe 1: Seite bauen
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stufe 2: nur die fertigen Dateien ausliefern.
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
Zwei Details daran sind wichtiger, als sie aussehen.
npm ci statt npm install, und die Lockfile wird explizit vor dem restlichen Quellcode kopiert. Beides zusammen: Der Build zieht exakt die Versionen aus der Lockfile, nichts anderes, und der Dependency-Layer wird nur dann neu gebaut, wenn sich die Lockfile tatsächlich geändert hat. npm install darf die Lockfile verändern, wenn es die Auflösung für besser hält — in einem CI-Lauf ist das kein Komfort, sondern eine unsichtbare Abweichung zwischen dem, was lokal getestet wurde, und dem, was ausgeliefert wird.
Das zweite Detail steht im Dockerfile als Kommentarblock, weil es sonst garantiert jemand — vermutlich ich selbst in einem halben Jahr — für einen Fehler hält: Das Image bringt bewusst keine eigene nginx-Konfiguration mit.
Der Zielcontainer auf dem Server hat seinen conf.d-Ordner vom Host gemountet, zusammen mit den Zertifikaten und den Redirect-Regeln. Ein Docker-Bind-Mount ersetzt den Zielordner im Image vollständig — hätte ich eine eigene Konfiguration ins Image gelegt, wäre sie beim Start stumm überdeckt worden. Kein Fehler, keine Warnung, nur die dauerhafte Verwunderung darüber, warum Änderungen keine Wirkung zeigen. Port, TLS und die listen-/root-Direktiven bleiben Sache des Servers. Das Image liefert Inhalt.
Der elegante Nebeneffekt: nginx:alpine bringt seine eigene default.conf mit, die zufällig genau auf /usr/share/nginx/html zeigt. Lokal lässt sich das Image dadurch eigenständig starten und testen, als wäre es ein vollwertiger Webserver. Auf dem Server wird genau diese Datei vom Host-Mount verdeckt. Dasselbe Image, zwei Rollen, ohne Sonderfall im Build.
Ein eigenes Validierungsskript hängt als prebuild-Hook in der package.json. npm führt es automatisch vor jedem npm run build aus — es prüft unter anderem, ob jeder Beitrag in beiden Sprachen unter demselben Dateinamen existiert.
Dass es ein Lifecycle-Hook ist und kein eigener Workflow-Schritt, ist der Punkt. Die zweite Stufe des Dockerfiles ruft npm run build auf, also läuft die Prüfung innerhalb des Image-Builds. Fehlt eine Übersetzung, schlägt der Docker-Build fehl und es entsteht gar kein Image — unabhängig davon, ob gerade die Pipeline baut oder jemand lokal an ihr vorbei. Eine Regel, die im Kopf eines Autors existiert, ist keine Regel; eine, die man nur dann umgeht, wenn man das Dockerfile ändert, schon.
Job 1: build
permissions:
contents: read
packages: write
Diese drei Zeilen stehen am Anfang der Workflow-Datei und sind der Teil, den man am leichtesten weglässt. Ohne sie bekommt der automatisch bereitgestellte GITHUB_TOKEN die Standardrechte des Repositories — deutlich mehr, als ein Build braucht. Mit ihnen darf der Token den Quellcode lesen und Pakete schreiben. Sonst nichts.
Dass es überhaupt der GITHUB_TOKEN ist und kein persönlicher Access Token, ist die zweite Hälfte derselben Entscheidung. Der Token wird pro Lauf erzeugt, läuft mit dem Lauf ab und lässt sich nicht versehentlich anderswo wiederverwenden. Ein PAT im Secret-Store wäre ein langlebiges Geheimnis, das jemand irgendwann rotieren müsste — und das dann niemand rotiert.
- name: Image-Namen in Kleinbuchstaben setzen
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/odabas-portfolio" >> "$GITHUB_ENV"
Dieser Schritt sieht nach Kosmetik aus und ist in Wahrheit eine Fehlerbehebung. Die GitHub Container Registry akzeptiert nur kleingeschriebene Image-Namen. Mein GitHub-Konto heisst WaveRider52, mit zwei Grossbuchstaben. ghcr.io/${{ github.repository_owner }}/odabas-portfolio direkt zu verwenden — der Weg, den fast jedes Tutorial zeigt — scheitert deshalb beim Push, mit einer Fehlermeldung, die den eigentlichen Grund nicht besonders deutlich nennt.
Die Lösung ist Bash-Parameter-Expansion: ${VAR,,} wandelt den Inhalt in Kleinbuchstaben. Das funktioniert, weil die Schritte auf einem Linux-Runner in Bash laufen — in der GitHub-Actions-Ausdruckssyntax ${{ }} gibt es kein Äquivalent dazu. Der Wert wird anschliessend über $GITHUB_ENV an die folgenden Schritte weitergereicht, statt ihn dreimal hinzuschreiben.
Der Rest des Jobs ist bewusst unspektakulär: die offiziellen Actions für Checkout, Registry-Login und Build-and-Push. Getaggt wird doppelt, mit latest und mit dem vollen Commit-SHA.
tags: |
${{ env.IMAGE }}:latest
${{ env.IMAGE }}:${{ github.sha }}
Die Doppelung ist Absicht. latest ist das, was die Compose-Datei auf dem Server referenziert — der Deploy-Schritt muss diese Datei dadurch nie anfassen. Der SHA-Tag ist das, was den Rollback überhaupt erst möglich macht. Nur latest zu pushen, wäre ein Deployment ohne Historie: Man weiss jederzeit, was gerade läuft, aber nicht mehr, was vorher lief.
Job 2: deploy
Der zweite Job hängt per needs am ersten und legt zuerst den SSH-Zugang an:
- name: SSH vorbereiten
env:
SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
Drei Kleinigkeiten darin sind bewusst so und nicht anders.
install -m 700 -d legt das Verzeichnis direkt mit den richtigen Rechten an. Die Alternative — mkdir und danach chmod — erzeugt für einen Moment ein Verzeichnis mit zu offenen Rechten. Auf einem Wegwerf-Runner ist das folgenlos, aber es ist die Sorte Gewohnheit, die man sich besser gleich richtig angewöhnt.
printf '%s\n' statt echo: echo interpretiert je nach Shell Backslash-Sequenzen. Ein SSH-Schlüssel ist mehrzeiliger Base64-Text, und ein Werkzeug, das daran herumdeutet, ist das Letzte, was man an dieser Stelle möchte. printf '%s' gibt den String unverändert aus, Punkt.
Und die Secrets gehen über den env:-Block, nicht direkt per ${{ }} in die Skriptzeile. Was in ${{ }} steht, wird vor der Ausführung in den Skripttext eingesetzt — der Schlüsselinhalt würde also Teil des Shell-Skripts selbst. Über env: landet er in einer Umgebungsvariable und wird nie als Code interpretiert.
Danach folgt genau ein Befehl:
ssh "$TARGET" "cd ~/projects && docker compose pull nginx-odabas && docker compose up -d --force-recreate nginx-odabas"
Das --force-recreate ist hier nicht optional, auch wenn es überflüssig aussieht. docker compose up -d vergleicht die Service-Definition mit dem laufenden Container. Die Definition ist unverändert — derselbe Name, dieselbe Tag-Referenz :latest — also sieht Compose keinen Grund, etwas zu tun. Der Tag ist gleich geblieben, das Image dahinter nicht. Ohne --force-recreate läuft der alte Container fröhlich weiter, der Job meldet Erfolg, und die Seite ändert sich nicht.
Was in dem Befehl bewusst fehlt: ein nginx -s reload, ein Eingriff in den Reverse Proxy, ein Anfassen der Zertifikate. Der Edge-Router erkennt den neuen Container über dessen Labels und routet automatisch weiter. Der Containername bleibt derselbe, also bleiben auch die Zertifikats-Renewal-Hooks unberührt, die an diesem Namen hängen. Der Austausch selbst dauert unter einer Sekunde.
Variable statt Secret
if: ${{ vars.DEPLOY_ENABLED == 'true' }}
DEPLOY_ENABLED ist eine Repository-Variable, kein Secret. Das ist keine Kosmetik. Ein Secret wird in den Logs maskiert — ein maskierter Schalter macht die Fehlersuche schwerer, ohne irgendetwas zu schützen, denn die Information „Deployment ist eingeschaltet” ist kein Geheimnis. Secrets sind für Dinge, deren Kenntnis Zugriff verschafft. Alles andere gehört in Variablen.
Der praktische Nutzen war grösser als erwartet. Die ersten Läufe fanden statt, während der Deploy-Schritt ausgeschaltet war: Das Image landete in der Registry, das Tagging liess sich prüfen, der Build-Job war grün — und der Server blieb dabei vollständig unberührt. In der Lauf-Historie stehen diese Durchgänge heute noch mit übersprungenem Deploy-Job. Erst als alles andere verifiziert war, wurde der Schalter umgelegt.
Ein Deployment-Schritt, den man erst scharf stellt, nachdem man ihn ohne Risiko durchgespielt hat, ist deutlich entspannter als einer, dessen erster Lauf gleichzeitig sein erster Ernstfall ist.
Der Deploy-Key
Die Pipeline hat einen eigenen SSH-Schlüssel, nicht den, mit dem ich mich selbst einlogge. Erzeugt wurde er direkt auf dem Server, der öffentliche Teil liegt dort in authorized_keys, der private als Secret im Repository.
Der Grund ist Eindämmung. Ein CI-Schlüssel liegt naturgemäss in einem System, das ich nicht vollständig kontrolliere. Wenn er kompromittiert wird, will ich genau diesen Schlüssel zurückziehen können — nicht meinen eigenen Zugang mit verlieren und mich anschliessend fragen, wie ich ohne Zugang auf den Server komme, um den Zugang zu reparieren.
Nebeneffekt der Erzeugung auf dem Server: Der private Schlüssel hat nie auf meinem Rechner gelegen und ist nie über einen Zwischenschritt gewandert.
Der Secret, den man weglässt
Vier Secrets braucht der Deploy-Job: Host, Benutzer, privater Schlüssel — und die bekannten Host-Keys aus ssh-keyscan.
Der vierte ist der, den Tutorials gern überspringen. Ohne ihn muss die Pipeline die Host-Key-Prüfung abschalten, üblicherweise mit StrictHostKeyChecking=no. Das ist die Prüfung, die verhindert, dass sich etwas anderes als der eigene Server als Ziel ausgibt. Ein automatisierter Deploy, der jeden Host akzeptiert, ist ein automatisierter Deploy auf irgendeinen Host — mit einem privaten Schlüssel im Gepäck.
Die Zeile aus ssh-keyscan -H zu hinterlegen kostet eine Minute. Sie abzuschalten spart diese Minute und tauscht sie gegen eine Schwachstelle, die niemandem auffällt, weil nichts kaputtgeht.
Auch eine funktionierende Pipeline altert
Die Läufe sind grün, und trotzdem stehen drei Annotations darunter. Sie sind der interessanteste Teil des Build-Logs.
Die erste: actions/checkout@v4, docker/login-action@v3 und docker/build-push-action@v6 deklarieren alle drei Node 20 als Laufzeit — eine Version, die GitHub als veraltet markiert hat. Der Runner führt sie deshalb bereits auf Node 24 aus. Das ist bemerkenswerter, als es klingt: Die Actions laufen heute auf einer Laufzeit, gegen die ihre Autoren sie nicht getestet haben, weil die deklarierte nicht mehr existiert. Es funktioniert, aber es funktioniert auf Kulanz.
Die zweite und dritte betreffen beide Jobs: Das Label ubuntu-latest wandert ab dem 19. Oktober 2026 auf Ubuntu 26.
Beides bricht heute nichts, und beides ist derselbe Mechanismus, gegen den ich mich beim Image schon entschieden habe: eine bewegliche Referenz. ubuntu-latest ist das :latest der Runner — derselbe Name, irgendwann ein anderes Betriebssystem. Und @v4 ist keine feste Version, sondern ein Tag, der innerhalb der Major-Linie mitwandert; welche Node-Laufzeit dahinter deklariert ist, entscheidet nicht meine Workflow-Datei, sondern der Stand, auf den dieser Tag gerade zeigt.
Der Unterschied zwischen einem stabilen und einem unerklärlich kaputten Freitagnachmittag ist oft nur, dass sich hinter einem unveränderten Namen etwas verändert hat — und dass man nicht weiss, wann.
Auf der Server-Seite habe ich die Konsequenz bereits gezogen: Die Compose-Definitionen bekommen explizite Versions-Tags statt latest. Für die Pipeline habe ich es inzwischen nachgezogen: runs-on: ubuntu-24.04 statt des wandernden Labels, die drei Actions auf ihre Node-24-Majors gehoben. Der nächste Lauf zeigte keine Annotations mehr. Ein angekündigter Bruch, den man vorher festnagelt, ist Wartung; derselbe Bruch drei Wochen später ist Feuerwehr.
Was die Pipeline bewusst nicht tut
Der ehrliche Teil, weil eine Pipeline ohne benannte Grenzen nur wie eine vollständige aussieht:
Kein automatischer Rollback. Schlägt der Deploy fehl oder liefert die Seite Unsinn aus, passiert nichts von selbst. Der SHA-Tag macht den Rollback manuell möglich — mehr ist es nicht.
Kein Health-Check nach dem Deploy. Der Job gilt als erfolgreich, wenn der ssh-Befehl ohne Fehler zurückkommt. „Der Container läuft” und „die Seite funktioniert” sind nicht dasselbe, und die Pipeline kennt nur Ersteres.
Keine Staging-Umgebung. Gebaut wird direkt gegen Produktion. Für eine statische Seite ohne Datenbank und ohne Nutzerdaten ist das verhältnismässig; für eine Anwendung mit Zustand wäre es fahrlässig.
Kein Aufräumen auf dem Server. Alte Images sammeln sich an, bis jemand sie wegräumt.
Das sind bekannte Lücken, keine übersehenen — und der Unterschied ist der eigentliche Punkt. Der Fehlerfall dieser Seite lautet: Der Blog ist ein paar Minuten kaputt, und ich setze einen Tag zurück. Automatisches Rollback mit Health-Check-Gate zu bauen, wäre Aufwand gegen ein Risiko, das nicht existiert. Bei den geplanten Anwendungen mit Datenbank sieht die Rechnung anders aus — und dann gehört es gebaut, nicht vorher aus Prinzip.
Ergebnis
Der Workflow reagiert auf zwei Auslöser: jeden Push auf main und, über workflow_dispatch, den Knopf in der Actions-Oberfläche. Der erste Lauf mit scharfgestelltem Deploy-Schritt wurde von Hand ausgelöst — 49 Sekunden, beide Jobs grün, die Seite danach im Browser bestätigt. Die Push-Läufe davor und danach liegen zwischen 37 und 52 Sekunden.
Seitdem gilt für gewöhnliche Inhalts-Updates: schreiben, committen, pushen. Der Server ist nicht mehr Teil des Arbeitsablaufs.
Wie der Umstieg von der alten Installation auf diese Architektur konkret ablief — inklusive des Moments, in dem eine einzige Zeile in der Compose-Datei geändert wurde — steht im nächsten Beitrag.