Einige Links auf dieser Seite sind Affiliate-Links: bei einer Anmeldung darüber erhält diese Seite eine Provision, für Sie ohne Mehrkosten. Preise und Empfehlungen bleiben davon unberührt.

Jedes Tutorial zum Thema „statische Seite mit GitHub Actions deployen" endet gleich: SSH-Schlüssel erzeugen, privaten Teil in die Repository-Secrets kleben, Build per rsync auf den Server schieben. Es funktioniert beim ersten Versuch, und genau das ist das Problem. Sie haben gerade ein interaktives Konto auf Ihrem Server angelegt, und es liegt in einem System, das Code aus Pull Requests ausführt.

Dieser Beitrag ist dieselbe Sache mit Leitplanken: ein Schlüssel, der genau eine Aufgabe kann, ein Hostkey, den Sie selbst gepinnt haben, und drei Prüfungen, die einen kaputten Build daran hindern, eine funktionierende Seite zu ersetzen. Das ist die Pipeline, die diesen Blog ausliefert, und sie kostet vielleicht fünfzehn Minuten zusätzlich.

Die Serverseite setzt voraus, dass Sie eine Maschine haben. Falls nicht: Die günstigen Klassen haben wir im VPS-Vergleich gegenübergestellt.

Was ein naiver Deploy-Key tatsächlich erlaubt

Ein Eintrag in ~/.ssh/authorized_keys ohne Optionen ist ein Login. Wer den privaten Schlüssel hat, kann:

  • eine interaktive Shell als dieser Benutzer öffnen,
  • alles lesen, was dieser Benutzer lesen darf, inklusive anderer Projekte auf derselben Maschine und jeder herumliegenden .env,
  • sudo benutzen, falls der Benutzer es darf,
  • Ports weiterleiten und Ihren Server als Sprungbrett in alles nutzen, was er erreicht.

Nichts davon braucht man, um ein Verzeichnis mit HTML zu kopieren. Und der private Schlüssel liegt in einem CI-System, in dem eine Workflow-Änderung, eine kompromittierte Third-Party-Action oder ein falsch konfigurierter pull_request_target-Trigger ihn auslesen kann.

Wir haben genau dieses Setup monatelang betrieben, ohne darüber nachzudenken - das ist der ehrliche Grund, warum es diesen Beitrag gibt.

Den Schlüssel auf einen Befehl festnageln

OpenSSH erlaubt Optionen pro Schlüssel in authorized_keys. Entscheidend ist command=: Es ersetzt das, was der Client ausführen wollte, durch das, was Sie festlegen.

command="/usr/bin/rrsync -wo /home/deploy/site",restrict ssh-ed25519 AAAA... github-actions

Stück für Stück:

  • rrsync kommt mit rsync mit (/usr/bin/rrsync unter Debian und Ubuntu; in älteren Versionen kann es ein gepacktes Skript unter /usr/share/doc/rsync/scripts/ sein). Es ist ein Wrapper, der nur den serverseitigen rsync-Aufruf akzeptiert und alles andere ablehnt.
  • -wo heißt write-only: Der Schlüssel darf in das Verzeichnis hochladen und nichts daraus herunterladen. Wenn Ihr Deployment nur schiebt, nehmen Sie die strengere Variante.
  • Der Pfad am Ende ist ein Gefängnis. Pfade außerhalb werden abgelehnt, auch die kreativen mit ...
  • restrict schaltet Portweiterleitung, Agent-Forwarding, X11 und PTY-Zuteilung in einem Wort ab. Auf jedem OpenSSH der letzten Jahre ist das besser, als no-port-forwarding,no-agent-forwarding,... von Hand aufzuzählen.

Testen Sie es direkt, von Ihrem Rechner aus mit diesem privaten Schlüssel:

ssh -i deploy_key deploy@ihr-server 'id'      # muss scheitern
rsync -rltz -e "ssh -i deploy_key" ./public/ deploy@ihr-server:/   # muss gelingen

Dass der erste Befehl scheitert, ist der ganze Sinn. Druckt er eine Benutzer-ID, greifen die Optionen nicht: Prüfen Sie, ob alles in einer Zeile steht und ob die Zeile mit command= beginnt und nicht mit dem Schlüsseltyp.

Beachten Sie den Zielpfad im rsync-Aufruf: Er ist relativ zum Gefängnis, / meint also genau das Verzeichnis, auf das Sie den Schlüssel festgelegt haben.

Den Hostkey pinnen, nicht die Prüfung abschalten

Die andere Abkürzung der Tutorials ist StrictHostKeyChecking=no - also die Prüfung abschalten, ob der Server, auf den Sie ausliefern, auch der Server ist, den Sie meinen. Pinnen Sie ihn stattdessen. Auf dem Server:

ssh-keyscan -t ed25519 ihr-server.example

Legen Sie diese Zeile als Repository-Secret ab und schreiben Sie sie im Job in known_hosts. Ein geänderter Hostkey lässt das Deployment dann laut scheitern, statt Ihre Seite still an denjenigen zu schicken, der unter dieser Adresse antwortet.

Der Workflow

name: Build and deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          submodules: recursive

      - name: Build
        run: ihr-build-befehl

      - name: Guard against an empty build
        run: |
          set -euo pipefail
          test -s public/index.html
          test -s public/sitemap.xml

      - name: Deploy over rsync
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
          DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
        run: |
          set -euo pipefail
          mkdir -p ~/.ssh
          printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
          rsync -rltz --delete --omit-dir-times \
            -e "ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o UserKnownHostsFile=~/.ssh/known_hosts" \
            public/ "$DEPLOY_USER@$DEPLOY_HOST:/"

      - name: Smoke test
        run: |
          set -euo pipefail
          for url in https://example.com/ https://example.com/sitemap.xml; do
            code=$(curl -s -o /dev/null -w '%{http_code}' --retry 3 --retry-delay 5 "$url")
            echo "$url -> $code"
            test "$code" = "200"
          done

Die Teile, die keine Dekoration sind:

concurrency ohne cancel-in-progress. Zwei gleichzeitige Deployments mit --delete auf dasselbe Verzeichnis sind der Weg zu einer halb kopierten Seite. Sie zu serialisieren kostet nichts.

Der Schutz gegen leere Builds. --delete macht aus rsync überhaupt erst ein Deployment - und verwandelt einen Build, der still nichts erzeugt hat, in eine gelöschte Website. Drei test -s-Zeilen sind billiger als dieser Ausfall. Prüfen Sie Dateien, die existieren müssen, und mindestens eine, die nur bei einem echten Build entsteht.

IdentitiesOnly=yes. Sonst bietet ssh womöglich zuerst andere Schlüssel aus dem Agenten an und wird abgewiesen, bevor Ihrer an die Reihe kommt.

--omit-dir-times. In einem Write-only-Gefängnis können rsyncs Versuche, Verzeichniszeiten zu setzen, lautstark scheitern. Das hält die Ausgabe sauber, ohne etwas aufzuweichen.

Der Smoke-Test läuft gegen die öffentliche URL, nicht per ssh gegen den Server. Es ist der einzige Schritt, der beweist, dass ein Besucher das Ergebnis sieht: Er fängt einen toten Container, ein abgelaufenes Zertifikat und einen Proxy, der auf das falsche Verzeichnis zeigt - von alldem weiß rsync nichts.

Wogegen das schützt und wogegen nicht

Es schützt vor dem realistischen Fall: Ein geleaktes CI-Secret wird zu Shell-Zugriff auf Ihrem Server. Der Schlüssel darf in ein Verzeichnis schreiben und sonst nichts, nicht zurücklesen, nicht weiterleiten, kein Terminal öffnen.

Es schützt nicht vor jemandem, der die Workflow-Datei ändern kann, denn dann wird einfach der ausgelieferte Inhalt ersetzt. Wenn Ihnen die Seite wichtig ist, schützen Sie den Branch: Review verlangen und das Deployment nur auf push in den Default-Branch laufen lassen, niemals auf pull_request_target, der Fork-Code mit Ihren Secrets ausführt.

Es schützt auch nicht das Verzeichnis selbst. Alles, was aus diesem Pfad ausgeliefert wird, kann der Schlüssel überschreiben. Halten Sie das Deployment-Ziel getrennt von Konfiguration, Datenvolumes und allem anderen auf dem Server.

Die Nachrüstung, Schritt für Schritt

Wenn Sie schon einen unbeschränkten Deploy-Key haben, ist die Korrektur in Minuten erledigt:

  1. Prüfen, ob rrsync vorhanden ist: ls -l /usr/bin/rrsync.
  2. authorized_keys bearbeiten und command="/usr/bin/rrsync -wo /pfad/zur/site",restrict vor die bestehende Schlüsselzeile setzen.
  3. Von Ihrem Rechner mit diesem Schlüssel prüfen, dass ssh ... 'id' scheitert und ein Test-rsync weiterhin gelingt.
  4. Den Workflow per workflow_dispatch erneut starten und zusehen, wie der Smoke-Test durchläuft.
  5. Erst dann die Sicherung der alten Zeile löschen.

Den Schlüssel bei dieser Gelegenheit auszutauschen, ist eine vernünftige Gewohnheit - besonders, wenn er je in einem Log stand.

Wie es weitergeht

Die Serverseite desselben Aufbaus: Caddy und Docker im Produktivbetrieb - automatisches HTTPS, mehrere Sites auf einer Maschine und die Cache-Header, die darüber entscheiden, ob Ihre Leser die gerade veröffentlichte Seite sehen.