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, sudobenutzen, 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:
rrsynckommt mit rsync mit (/usr/bin/rrsyncunter 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.-woheiß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
... restrictschaltet Portweiterleitung, Agent-Forwarding, X11 und PTY-Zuteilung in einem Wort ab. Auf jedem OpenSSH der letzten Jahre ist das besser, alsno-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:
- Prüfen, ob
rrsyncvorhanden ist:ls -l /usr/bin/rrsync. authorized_keysbearbeiten undcommand="/usr/bin/rrsync -wo /pfad/zur/site",restrictvor die bestehende Schlüsselzeile setzen.- Von Ihrem Rechner mit diesem Schlüssel prüfen, dass
ssh ... 'id'scheitert und ein Test-rsync weiterhin gelingt. - Den Workflow per
workflow_dispatcherneut starten und zusehen, wie der Smoke-Test durchläuft. - 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.