GitHub Actions ist die CI/CD-Plattform, die direkt in deinem GitHub-Repository läuft: Du legst eine YAML-Datei unter `.github/workflows/` ab, und GitHub startet die darin beschriebenen Jobs, sobald ein Ereignis eintritt, etwa ein Push, ein Pull Request oder ein Zeitplan. Für GitHub Actions CI/CD brauchst du deshalb keinen eigenen Build-Server. Die eigentliche Arbeit liegt woanders: Jobs so schneiden, dass Pull Requests schnell grün werden, Deploys nur von `main` auslösen und den Minutenverbrauch planbar halten.
Die Beispiele unten stammen aus dem Projekttyp, den ich baue: Next.js mit TypeScript, eine PostgreSQL-Datenbank dahinter, Auslieferung auf eine Hosting-Plattform oder einen eigenen Server. Wer einen anderen Stack nutzt, tauscht die `run`-Zeilen aus. Die Struktur bleibt.
Fünf Bausteine und die YAML-Zeile, in der jeder steckt
Zwei Begriffe vorweg, weil der Rest darauf aufbaut. Continuous Integration (CI) heißt: Jede Änderung wird automatisch gebaut und getestet, bevor sie in den Hauptzweig gelangt. Continuous Delivery (CD) heißt: Was im Hauptzweig liegt, ist auslieferbar – und bei Continuous Deployment geht es ohne manuellen Klick live.
GitHubs eigene Übersicht Grundlegendes zu GitHub Actions zerlegt die Plattform in Workflows, Ereignisse, Aufträge (Jobs), Aktionen und Runner. Im YAML sieht das so aus:
| Baustein | Was er ist | Wo er im YAML steht | Typischer Fehler |
|---|---|---|---|
| Workflow | Automatisierte Abfolge, definiert in einer Datei | Datei unter `.github/workflows/` | Datei liegt in `.github/workflow/` und wird nicht erkannt |
| Ereignis | Der Auslöser | `on:` | `push` ohne Branch-Filter startet den Workflow auf jedem Feature-Branch |
| Job | Gruppe von Schritten auf einem Runner | `jobs.<id>` | Deploy-Job ohne `needs:` läuft parallel zum Test |
| Aktion | Wiederverwendbarer Baustein, etwa `actions/checkout` | `uses:` in einem Step | Referenz auf einen Branch statt auf Tag oder Commit |
| Runner | Die Maschine, die den Job ausführt | `runs-on:` | `ubuntu-latest` wird periodisch auf eine neuere Ubuntu-Version umgestellt |
Jobs laufen parallel, Steps nacheinander
Innerhalb eines Jobs laufen die Steps der Reihe nach auf derselben Maschine. Mehrere Jobs starten dagegen standardmäßig parallel, jeder auf einem eigenen Runner; eine Reihenfolge erzwingst du mit `needs:`, so beschreibt es die GitHub-Dokumentation.
Daraus folgt der häufigste Anfängerfehler. Job A baut die App, Job B soll den `.next`-Ordner deployen – und findet ihn nicht, weil B auf einer anderen, frischen Maschine startet. Dateien wandern zwischen Jobs nur über Artefakte (`actions/upload-artifact`, `actions/download-artifact`) oder über einen Cache.
Wer das nicht will, packt Build und Deploy in einen Job. Dann verlierst du die saubere Trennung zwischen Prüfung und Auslieferung.
Ein Workflow für Next.js: CI auf jedem Pull Request, CD nur von main
Diesen Workflow würde ich als Ausgangspunkt für ein kleines Web-App-Projekt ansetzen. Er prüft jeden Pull Request und deployt nur, wenn ein Commit auf `main` landet oder jemand den Lauf von Hand startet.
```yaml name: ci-cd
on: pull_request: push: branches: [main] workflow_dispatch:
concurrency: group: $-$ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions: contents: read
jobs: verify: runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version-file: .nvmrc cache: npm - run: npm ci - run: npm run lint - run: npm test - run: npm run build
deploy: needs: verify if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' runs-on: ubuntu-24.04 timeout-minutes: 10 environment: production steps: - uses: actions/checkout@v7 - name: Deploy run: ./scripts/deploy.sh env: DEPLOY_SSH_KEY: $ DEPLOY_HOST: $ ```
Das Deploy-Skript ist projektspezifisch: Es kann per `rsync` auf einen VPS kopieren, `docker compose` auf dem Server anstoßen oder die CLI einer Hosting-Plattform aufrufen. Welcher Weg passt, entscheidet die Hosting-Wahl, nicht GitHub Actions. Prüfe vor dem Einsatz außerdem, welche Hauptversion von `actions/checkout` und `actions/setup-node` aktuell ist.
Nach dem ersten Push siehst du den Lauf im Tab „Actions". Der Workflow-Visualizer zeigt, welcher Job auf welchen wartet, die Live-Logs zeigen jeden Step – GitHub geht diese Ansicht im Blogbeitrag zum Aufbau einer CI/CD-Pipeline Schritt für Schritt durch.
Was die Zeilen tun, die in Tutorials oft fehlen
- `concurrency` gruppiert Läufe pro Workflow und Branch. `cancel-in-progress` bricht einen älteren Lauf ab, sobald ein neuer Push auf denselben Branch kommt – hier aber nur bei Pull Requests. Auf `main` würde derselbe Schalter einen halb fertigen Deploy abwürgen, wenn zwei Merges kurz nacheinander eintreffen. Mit dem Ausdruck wartet der neue Lauf dort, bis der laufende fertig ist. Laut GitHub-Dokumentation ersetzt er dabei allerdings einen älteren, noch wartenden Lauf derselben Gruppe – ausgerollt wird also der neueste Stand. Soll jeder Commit einzeln durchlaufen, sieht die Dokumentation dafür die Eigenschaft `queue: max` vor.
- `permissions: contents: read` beschränkt das automatisch bereitgestellte `GITHUB_TOKEN` auf Lesezugriff. Welche Rechte das Token ohne diese Zeile hat, hängt von Repository- und Organisationseinstellungen ab. Die explizite Zeile nimmt diese Unklarheit raus.
- `timeout-minutes` begrenzt die Laufzeit eines Jobs. Ohne die Angabe greift laut Workflow-Syntax-Referenz von GitHub ein Standardlimit von 360 Minuten; warum das teuer wird, zeigt die Rechnung weiter unten.
- `environment: production` hängt den Deploy-Job an eine Umgebung. Dort liegen umgebungsspezifische Secrets und Schutzregeln. Beides hängt vom Plan ab: Mit GitHub Free gibt es Environment-Secrets nur in öffentlichen Repositories, und erforderliche Reviewer als manuelle Freigabe bieten Free, Pro und Team laut GitHub-Dokumentation ebenfalls nur für öffentliche Repositories an.
- `actions/checkout` klont das Repository in den Runner, standardmäßig nur den letzten Commit (`fetch-depth: 1`). Braucht ein Schritt die Git-Historie, etwa für `git describe` oder ein Changelog-Tool, setzt du `fetch-depth: 0`.
- `node-version-file: .nvmrc` liest die Node-Version aus dem Repository. Die CI baut dann mit derselben Hauptversion wie dein Rechner und der Server, statt mit einer Zahl, die im YAML still veraltet.
Pull Requests aus Forks bekommen keine Secrets
Bei `pull_request`-Ereignissen aus einem Fork reicht GitHub standardmäßig keine Repository-Secrets an den Workflow durch, und das `GITHUB_TOKEN` erhält nur Leserechte. Das ist Absicht: Fremder Code soll nicht an deinen Deploy-Schlüssel kommen.
Der Umweg über `pull_request_target` läuft zwar mit Secrets, aber im Kontext des Standardbranches des Basis-Repositorys. Checkt ein solcher Workflow den Fork-Code aus und führt ihn aus, hat der Fork-Autor genau den Zugriff, den die Sperre verhindern sollte. `actions/checkout` ab v7 verweigert das Auschecken von Fork-Code in diesem Kontext, solange `allow-unsafe-pr-checkout` nicht gesetzt ist; eigene Skripte, die den Fork-Stand anders holen, schützt das nicht. In privaten Firmen-Repositories ohne externe Forks spielt das selten mit. Bei öffentlichen Repositories ist es die erste Stelle, die du prüfst.
Wann sich eine eigene Action lohnt
Meine Faustregel: Solange sich Schritte nur innerhalb eines Repositories wiederholen, bleibt normales YAML einfacher. Tauchen dieselben zehn Zeilen in drei oder mehr Repositories auf, lohnt der Umbau.
- Reusable Workflow (`on: workflow_call`): Ein ganzer Workflow, den andere Repositories auf Job-Ebene per `uses:` einbinden. Passt, wenn die komplette Pipeline gleich ist.
- Composite Action (`action.yml` mit `using: composite`): Eine Folge von Steps, die sich in fremde Jobs einsetzen lässt. Passt, wenn nur ein Block gleich ist, etwa „Node einrichten, installieren, bauen".
JavaScript- und Docker-Actions sind der dritte Weg. Sie rechnen sich erst, wenn echte Logik dazukommt, die sich in Shell-Zeilen nicht mehr lesbar ausdrücken lässt.
Minuten rechnen, bevor die Abrechnung es tut
Auf GitHub-gehosteten Runnern verbraucht jede Pipeline Minuten, und der Verbrauch hängt weniger an der Größe des Projekts als an der Frage, was bei welchem Ereignis läuft. Welche Minuten dein Plan enthält und wie Linux-, Windows- und macOS-Runner gewichtet werden, steht in der Abrechnungsübersicht deines Kontos. Die Konditionen können sich ändern, deshalb bleibt die folgende Rechnung bewusst in Minuten statt in Euro.
Die Annahmen
Ein Team mit drei Personen, ein privates Repository, Linux-Runner, ein Monat:
- 40 Pull Requests mit durchschnittlich 5 Pushes → 200 PR-Läufe
- 40 Merges auf `main` → 40 Läufe mit Deploy
- Verify-Job ohne Cache 8 Minuten, mit npm-Cache 6 Minuten, je Job auf volle Minuten aufgerundet
- Deploy-Job 3 Minuten
- 20 % der Pushes ändern nur Dokumentation
- Ein Viertel der verbleibenden PR-Läufe wird von einem neueren Push überholt; ein abgebrochener Lauf verbraucht im Schnitt 2 Minuten
Die naive Variante testet jeden Push gegen zwei Node-Versionen (Matrix), ohne Cache und ohne Filter.
Vier Hebel, in Rechenreihenfolge
| Schritt | PR-Minuten | main-Minuten | Summe | Ersparnis |
|---|---|---|---|---|
| Naiv: Matrix mit 2 Versionen, kein Cache | 3.200 | 760 | 3.960 | – |
| + npm-Cache (8 → 6 Minuten je Verify-Job) | 2.400 | 600 | 3.000 | −960 |
| + Matrix nur auf `main`, PRs mit einer Version | 1.200 | 600 | 1.800 | −1.200 |
| + `paths-ignore` für reine Doku-Änderungen | 960 | 600 | 1.560 | −240 |
| + Abbruch überholter PR-Läufe | 800 | 600 | 1.400 | −160 |
Unter diesen Annahmen sinkt der Verbrauch von 3.960 auf 1.400 Minuten, knapp 65 % weniger. Die Reihenfolge verschiebt die Einzelwerte etwas, das Muster bleibt: Der größte Hebel ist nicht der Cache, sondern die Frage, was auf einem Pull Request überhaupt laufen muss.
Eine zweite Node-Version auf jedem Push prüft dieselbe Kompatibilität 200-mal im Monat. Der Preis des Schnitts ist ehrlich zu benennen: Ein Bruch in der zweiten Version fällt erst nach dem Merge auf, bleibt aber vor dem Deploy hängen, weil `deploy` auf `verify` wartet. Für eine Web-App, die auf genau einer Node-Version läuft, kannst du die zweite Version oft ganz streichen. Wer sie behält, steuert die Matrix über das Ereignis (Ausschnitt, ersetzt `node-version-file`):
```yaml verify: strategy: matrix: node: ${{ github.event_name == 'pull_request' && fromJSON('["22"]') || fromJSON('["22", "24"]') }} steps: - uses: actions/setup-node@v7 with: node-version: $ cache: npm ```
Beachte dabei: Mit einer Matrix heißt der Job in den Branch-Schutzregeln etwa `verify (22)` statt `verify`; eine dort eingetragene Pflichtprüfung musst du entsprechend anpassen.
Der dritte Hebel hat eine Falle. Ist der Verify-Job in den Branch-Schutzregeln als erforderliche Prüfung eingetragen und der Workflow startet wegen `paths-ignore` gar nicht erst, bleibt die Prüfung auf „ausstehend". Der Merge-Button bleibt gesperrt. Die Lösung: Den Workflow starten lassen und innerhalb des Jobs prüfen, ob relevante Dateien geändert wurden; die teuren Steps werden übersprungen, der Job meldet trotzdem Erfolg.
Der teuerste Einzelfall: ein hängender Job
Ein Test lässt eine Datenbankverbindung offen, und der Testprozess beendet sich nicht. Jest meldet dann zwar, dass es sich nach dem Lauf nicht beendet hat – der Runner wartet trotzdem weiter.
Ohne `timeout-minutes` läuft dieser Job bis zum Standardlimit von 360 Minuten. Mit `timeout-minutes: 15` ist nach 15 Minuten Schluss. Ein Hänger ohne Timeout verbraucht also das 24-Fache und im Beispiel 25,7 % des bereinigten Monatsbudgets von 1.400 Minuten. Gut sieben solcher Hänger im Monat, und die gesamte Ersparnis aus der Tabelle ist wieder weg.
Meine Regel für den Wert: etwa das Doppelte der üblichen Laufzeit, aufgerundet. Bei 6 Minuten also 12 bis 15.
Deploy kontrollieren: Was zwischen grünem Build und Live-Server passiert
Im Runner bauen oder auf dem Server bauen
Zwei Muster sind verbreitet, und beide haben ihren Platz.
| Build im Runner, Artefakt ausliefern | Build auf dem Zielserver | |
|---|---|---|
| Was live geht | Genau das getestete Ergebnis | Ein zweiter Build aus demselben Commit |
| Build-Zeit-Variablen | Müssen im Runner als Secret vorliegen | Liegen auf dem Server |
| Last beim Deploy | Gering auf dem Server | Build läuft neben der laufenden App |
| Rollback | Vorheriges Artefakt oder Image erneut ausrollen | Alten Commit auschecken und neu bauen |
Bei Next.js kippt die Entscheidung oft an einer Stelle: Variablen mit dem Präfix `NEXT_PUBLIC_` werden beim Build in den Client-Code geschrieben. Wer im Runner baut, braucht dort die Produktionswerte, sonst steht im ausgelieferten Bundle die Test-URL. Wer auf dem Server baut, umgeht das, liefert aber streng genommen nicht das Artefakt aus, das die CI geprüft hat.
Die Entscheidungsregel: Sobald mehr als eine Umgebung dasselbe Ergebnis bekommen soll, baust du im Runner ein Container-Image, taggst es mit dem Commit-SHA (`$`), schiebst es in eine Registry und ziehst es auf dem Server nur noch. Für einen einzelnen Server mit einer App reicht der Build vor Ort, wie im Workflow oben.
Secrets, Schlüssel und OIDC
- Deploy-Schlüssel gehören als Environment-Secret in die Umgebung `production`, nicht als Repository-Secret, auf das jeder Workflow im Repository zugreifen kann – sofern dein Plan Environment-Secrets für das Repository anbietet.
- Der SSH-Schlüssel gehört zu einem eigenen Deploy-Benutzer, der nur das Zielverzeichnis beschreiben und die App neu starten darf – nicht zu `root`.
- OpenID Connect (OIDC) ist ein Standard, mit dem der Workflow seine Identität über ein kurzlebiges Token nachweist. Unterstützt dein Cloud-Anbieter das, tauschst du damit dauerhafte Zugangsschlüssel im Secret-Speicher gegen Zugangsdaten, die nach dem Lauf verfallen.
- GitHub maskiert Secrets in Logs nur in der Form, in der sie gespeichert sind. Ein Base64-kodierter oder zerlegter Schlüssel kann unmaskiert im Log erscheinen; GitHub empfiehlt, umgewandelte Werte ebenfalls als Secret zu registrieren.
Rollback proben, solange es ruhig ist
Ein Deploy-Skript ist erst fertig, wenn es denselben Commit zweimal hintereinander ausrollen kann, ohne dass etwas bricht. Starte den Deploy-Job bewusst über „Re-run jobs" erneut. Rolle danach den vorletzten Stand aus, etwa per Revert-Commit auf `main`; ein `workflow_dispatch` allein startet im Workflow oben nur den aktuellen Stand von `main` neu. Scheitert einer der beiden Schritte, erfährst du es jetzt und nicht an einem Freitagabend.
Vor dem ersten Produktions-Deploy prüfe ich diese acht Punkte:
- Deploy-Job hat `needs:` auf den Verify-Job und eine `if:`-Bedingung auf `main`.
- Jeder Job hat ein `timeout-minutes`.
- `permissions` ist explizit gesetzt und so eng wie möglich.
- Actions von Dritten sind auf einen Commit-SHA gepinnt – Tags lassen sich nachträglich verschieben, ein SHA nicht.
- Deploy-Secrets liegen als Environment-Secrets vor, soweit Plan und Repository-Sichtbarkeit das zulassen.
- `concurrency` bricht auf dem Deploy-Pfad keinen laufenden Deploy ab.
- Der Deploy wurde zweimal hintereinander ausgerollt, ohne Fehler.
- Der Rollback auf den vorletzten Stand wurde einmal durchgespielt.
GitHub Actions oder Jenkins: fünf Fragen für die Entscheidung
Jenkins ist ein quelloffener Automatisierungsserver, den du selbst betreibst oder betreiben lässt; Pipelines stehen dort meist in einem `Jenkinsfile` mit Groovy-basierter Syntax, Erweiterungen kommen als Plugins auf den Server. GitHub Actions ist in GitHub eingebaut und bringt laut Produktseite gehostete Runner für Linux, macOS und Windows sowie Matrix-Builds mit.
| Frage | Spricht für GitHub Actions | Spricht für Jenkins |
|---|---|---|
| Wo liegt der Code? | Auf GitHub | Verteilt auf mehrere Git-Hoster oder intern |
| Wer pflegt den CI-Server? | Niemand im Team hat dafür Zeit | Jemand pflegt Server und Plugins verlässlich |
| Brauchen Builds Hardware im eigenen Netz? | Nein, oder selbst gehostete Runner reichen | Ja, und die Jenkins-Infrastruktur steht bereits |
| Wie verzweigt ist die Logik? | Überwiegend lineare Schritte, Matrix, Umgebungen | Stark verzweigte Pipelines mit viel Skriptlogik |
| Was existiert schon? | Neues Projekt ohne Altlasten | Viele gepflegte Jobs, deren Umzug ein eigenes Budget braucht |
Für kleine Teams gilt bei mir: Liegt der Code auf GitHub und kann niemand einen Jenkins-Server zuverlässig aktuell halten, spricht kaum etwas für Jenkins. Umgekehrt ist eine laufende, gepflegte Jenkins-Landschaft kein Grund zum Umzug, nur weil ein neues Repository dazukommt.
Oktober und November: die Pipeline vor dem Jahresende aufräumen
Plant dein Unternehmen das Budget fürs nächste Jahr im Herbst, fallen zwei Dinge zusammen. Die Minutenrechnung wird jetzt gebraucht. Und kurz vor den Feiertagen will kaum jemand eine kaputte Deploy-Pipeline reparieren.
- Echte Zahlen statt Annahmen: Die Rechnung von oben mit den tatsächlichen Laufzeiten der letzten drei Monate füllen. Die Nutzung je Workflow findest du in der Nutzungs- und Abrechnungsübersicht deines Kontos oder deiner Organisation.
- Warnungen im Log lesen: Hinweise auf veraltete Action-Versionen oder Laufzeitumgebungen erscheinen als Annotation im Lauf, bevor aus der Warnung ein Fehler wird. Wer sie im Oktober abarbeitet, muss es im Dezember nicht unter Zeitdruck tun.
- Runner-Version festlegen: Weil `ubuntu-latest` periodisch auf eine neuere Version wandert, setzt du vor einem Code-Freeze eine konkrete Version wie `ubuntu-24.04` und planst den Wechsel selbst.
- Rollback proben: Den Durchlauf aus dem Deploy-Abschnitt einmal machen, solange noch alle erreichbar sind.
Häufige Fragen
Ist GitHub Actions ein CI/CD-Werkzeug?
Ja. GitHub beschreibt Actions in der offiziellen Dokumentation als Plattform für Continuous Integration und Continuous Delivery, mit der sich Build, Test und Deployment automatisieren lassen. Dieselbe Plattform startet Workflows auch bei anderen Repository-Ereignissen, etwa beim Anlegen eines Issues.
Kann GitHub für CI/CD genutzt werden?
Ja, auf zwei Wegen. Der direkte ist GitHub Actions mit Workflows im Repository. Der zweite: Ein externer CI-Dienst oder ein eigener Jenkins meldet seine Ergebnisse als Status-Check an den Pull Request zurück – GitHub bleibt dann Code-Hoster und Merge-Schranke, gebaut wird woanders.
Ist GitHub Actions CI/CD kostenlos?
Das hängt von drei Dingen ab: Sichtbarkeit des Repositories, GitHub-Plan und Runner-Typ. Freikontingente und Minutenpreise stehen in der Abrechnungsübersicht deines Kontos; prüfe sie vor der Planung, weil GitHub Konditionen anpassen kann. Im Rechenbeispiel oben hing der Verbrauch stärker an der Workflow-Struktur als an der Projektgröße.
Was unterscheidet Jenkins und GitHub Actions?
Jenkins ist ein Automatisierungsserver, den du selbst betreibst und über Plugins erweiterst; Pipelines stehen meist in einem Groovy-basierten `Jenkinsfile`. GitHub Actions ist in GitHub integriert, beschreibt Pipelines in YAML und bietet gehostete Runner. Entscheidend sind vor allem der Standort des Codes und die Frage, wer den CI-Server pflegt – die fünf Fragen oben ordnen das ein.