Voraussetzung: ein eingerichteter Server – einmaliger Aufbau in Server-Basis, wegwerfen & identisch neu in Snapshot & Restore. Was die Bausteine hier kosten: Kosten & Budget.
Ablauf & Entscheidungen
git push auf main (oder ein Tag v1.2.3) startet den Workflowsha + latest, schiebt nach ghcr.io/nursude/appproduction: optionaler manueller Klick / Wartezeit / erlaubte Branchescompose pull && up -d| Frage | Empfehlung |
|---|---|
| Image bauen auf dem Runner oder dem Server? | Runner – Server bleibt klein, keine Build-Tools drauf |
| Registry oder direkt kopieren? | GHCR – versioniert, Rollback = alten Tag ziehen |
| Push (Runner → Server) oder Pull (Server holt sich's)? | Push zum Start (einfach), Pull, wenn keine CI-Credentials auf dem Server sollen |
| Compose-Datei – wo? | auf dem Server (in Git verwaltet via deploy.ps1/rsync). CI (Continuous Integration) ändert nur den Image-Tag. |
| Self-hosted Runner? | Nur bei ARM-Builds, sehr großen Builds oder Zugriff auf ein privates Netz. Sonst GitHub-hosted. |
1 Image bauen & nach GHCR
name: deploy
on:
push:
branches: [ main ]
tags: [ 'v*' ]
concurrency:
group: deploy-prod
cancel-in-progress: false # Deploys nicht mittendrin abbrechen
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # fuer den Push nach GHCR
outputs:
tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # automatisch, kein eigenes Secret
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/nursude/app
tags: |
type=sha,format=long
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Zwei Ebenen in einer Datei: GitHub wertet zuerst alles in ${{ }} aus
und schreibt das Ergebnis in ein Shell-Skript – erst dann läuft die Shell.
Die meisten Verständnisfragen hier kommen daher.
| Konstrukt | Stelle | bewirkt |
|---|---|---|
on: { push: { branches: [main], tags: ['v*'] } } |
Kopf | Der Auslöser. Beides zusammen heißt: jeder Push auf main und jeder Tag, der mit v beginnt. Ein Release-Tag löst den Lauf also ein zweites Mal aus – gewollt, wenn man Releases getrennt nachvollziehen will, überraschend, wenn nicht. |
concurrency: { group: deploy-prod, cancel-in-progress: false } |
Kopf | Höchstens ein Lauf dieser Gruppe gleichzeitig; weitere warten. cancel-in-progress: false ist bei einem Deployment die wichtigere Hälfte: ein laufendes Rollout wird nicht abgebrochen, sonst bliebe der Server in einem halben Zustand stehen. Bei reinen Test-Workflows wäre true richtig. |
permissions: { contents: read, packages: write } |
build | Beschränkt, was das automatische Token darf. packages: write ist nötig, um in die GitHub-Registry zu schieben; contents: read genügt zum Auschecken. Ohne die Zeile gelten die Vorgaben des Repositories – oft mehr als gebraucht. Weniger Rechte hier bedeutet weniger Schaden, falls eine eingebundene Action einmal kompromittiert ist. |
${{ secrets.GITHUB_TOKEN }} |
Anmeldung | Muss man nicht anlegen – GitHub erzeugt es für jeden Lauf neu und zieht es danach wieder ein. Genau deshalb steht hier kein selbst erstelltes Token: kurzlebig, auf dieses Repository beschränkt und durch die Zeile darüber in seinen Rechten begrenzt. |
uses: docker/build-push-action@v6context: . |
build | Bauen und Schieben in einem Schritt. context ist das Verzeichnis, das an den Bauvorgang übergeben wird – der Punkt ist die Repository-Wurzel, in der auch das Dockerfile erwartet wird. push: true lädt gleich hoch; ohne das bläbe das Image auf dem Runner und wäre mit ihm weg. |
cache-from: type=ghacache-to: type=gha,mode=max |
build | Legt den Build-Cache in GitHubs eigenen Cache – der Runner ist bei jedem Lauf neu, ohne das beginnt jeder Build bei null. mode=max sichert auch die Zwischenschichten, nicht nur die letzte: deutlich schneller bei mehrstufigen Dockerfiles, dafür grösser. Der Cache ist auf 10 GB je Repository begrenzt, älteste Einträge fallen automatisch heraus. Braucht setup-buildx-action davor – ohne buildx gibt es kein type=gha. |
environment: production |
deploy | Bindet den Job an eine in GitHub definierte Umgebung. Dort lassen sich eigene Secrets hinterlegen – und eine Freigabe durch eine Person erzwingen, bevor der Job startet. Die Zeile ist der Ort, an dem aus einem automatischen Deployment ein bestätigtes wird. |
uses: tailscale/github-action@v3 |
deploy | Nimmt den Runner vorübergehend ins eigene Tailnet auf. Erst dadurch ist der Server über seinen *.ts.net-Namen erreichbar – ohne einen SSH-Port ins offene Netz zu öffnen. tags: tag:ci gibt dem Runner eine Kennzeichnung, auf die sich die Zugriffsregeln im Tailnet beziehen können. |
install -m700 -d ~/.sshchmod 600 ~/.ssh/id_ed25519 |
Schlüssel | SSH verweigert den Dienst, wenn Verzeichnis oder Schlüssel für andere lesbar sind – auch auf einem Runner, den nur dieser Lauf benutzt. Die Meldung dazu (UNPROTECTED PRIVATE KEY FILE) nennt zwar den Grund, taucht aber mitten in der Protokollausgabe auf. |
SSH_KNOWN_HOSTS |
Schlüssel | Der hinterlegte Host-Key des Zielservers. Er steht hier, damit SSH die Gegenstelle prüfen kann, ohne dass jemand eine Rückfrage beantwortet – die bequeme Alternative StrictHostKeyChecking=no würde jede Warnung abschalten und damit genau die Prüfung aufgeben, um die es geht. |
printf '%s\n' "…" |
Schlüssel | printf statt echo, weil echo je nach Shell mit Backslashes unterschiedlich umgeht – bei einem mehrzeiligen Schlüssel darf sich nichts verändern, sonst ist er unbrauchbar. |
bash -s <<'EOF' (mit Anführungszeichen) |
deploy | Ein Here-Dokument: alles bis zur Zeile EOF geht über die Standardeingabe an die Remote-Shell, die es dank -s als Skript ausführt. Die Anführungszeichen um 'EOF' verhindern, dass die lokale Shell darin Variablen ersetzt – $s und $i aus der Warteschleife sollen erst auf dem Zielserver aufgelöst werden. Die ${{ }}-Ausdrücke darin funktionieren trotzdem, denn die setzt GitHub schon ein, bevor die Shell die Datei überhaupt sieht. Genau diese Kombination macht die Zeile schwer lesbar – und richtig. |
docker inspect -f '{{.State.Health.Status}}' |
Warteschleife | Fragt den Gesundheitszustand des Containers ab – Go-Template-Syntax, dieselbe wie bei hcloud describe. Setzt voraus, dass die compose-Datei einen healthcheck definiert; ohne den bleibt das Feld leer, das || echo none fängt das ab und die Schleife läuft ergebnislos bis zum Ende. |
Nach dem ersten Push liegt das Paket unter . Für ein privates Repo ist auch das Image privat – der Server braucht dann ein Pull-Secret (Abschnitt 5). Bei „Inherit access from repository“ passt das automatisch.
Wenn der Zielserver cax/ARM (Advanced RISC Machines) ist: docker/setup-qemu-action@v3 davor
und platforms: linux/amd64,linux/arm64 im build-push-Step. QEMU-Builds sind
langsam – für reine ARM-Ziele lohnt ein ARM-Runner.
2 Zugang zum Server
Ein eigener Deploy-Key, ein eigener Unix-Nutzer – nicht dein persönlicher
SSH-Key, nicht root.
useradd -m -s /bin/bash -G docker deploy
install -d -m700 -o deploy -g deploy /home/deploy/.ssh
# Deploy-Key auf dem Laptop erzeugen (ohne Passphrase, CI kann keine eingeben):
# ssh-keygen -t ed25519 -f deploy_ci -N '' -> deploy_ci (privat, ins GitHub-Secret) + deploy_ci.pub
echo 'ssh-ed25519 AAAA... deploy-ci' > /home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys && chmod 600 $_
- Der
deploy-Nutzer ist in derdocker-Gruppe – das reicht fürdocker compose, keinsudo. - Noch enger: den Key auf einen Befehl beschränken
(
command="/opt/bin/deploy $SSH_ORIGINAL_COMMAND",restrictinauthorized_keys). - Host-Key vorab kennen:
ssh-keyscan projekthostausführen und das Ergebnis als SecretSSH_KNOWN_HOSTSablegen – dann keinStrictHostKeyChecking=accept-newnötig.
3 Secrets in GitHub
| Ort | wofür |
|---|---|
| Repository secrets | alles für alle Läufe (Build-Args ohne Bezug zur Umgebung) |
| Environment secrets | Deploy-Key, DB-Passwort, Tailscale-OAuth – nur der deploy-Job kommt dran, und nur nach der Freigabe |
secrets.GITHUB_TOKEN | automatisch, kurzlebig – GHCR-Push, Deployments-API. Kein eigenes Secret nötig. |
- Environment
productionanlegen → Required reviewers (du selbst), optional Wait timer, Deployment branches:mainund Tags. - Environment-Secrets hinterlegen:
SSH_KEY,SSH_KNOWN_HOSTS,TS_OAUTH_CLIENT_ID,TS_OAUTH_SECRET, App-Secrets. - Nie
echo "$SECRET"– GitHub maskiert bekannte Secrets im Log, aber abgeleitete Werte (base64 …) nicht.::add-mask::für selbst erzeugte.
4 Deploy per SSH
Die Compose-Datei liegt schon auf dem Server. Der Deploy-Job setzt nur den neuen Tag und startet neu.
Vom git push bis zum laufenden Container – und an welcher Stelle das Secret liegt.
Zeichnung aus Diagramme: Hetzner & CRC (04).
latest wäre
bequemer und würde genau diese Zuordnung zerstören.# compose.yaml -- Image-Tag kommt aus .env
services:
app:
image: ghcr.io/nursude/app:${APP_TAG:-latest}
restart: unless-stopped
env_file: [ app.env ]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 10s
retries: 6
networks: [ web ]
networks: { web: { name: web, external: true } }
deploy:
needs: build
runs-on: ubuntu-latest
environment: production # -> Freigabe + Environment-Secrets
steps:
- name: SSH-Key einrichten
run: |
install -m700 -d ~/.ssh
printf '%s\n' "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/id_ed25519
- name: Deploy
run: |
ssh deploy@projekthost bash -s <<'EOF'
set -euo pipefail
cd /opt/stacks/app
echo "APP_TAG=sha-${{ github.sha }}" > .env
docker compose pull
docker compose up -d --remove-orphans
# auf "healthy" warten, sonst Fehler
for i in $(seq 1 30); do
s=$(docker inspect -f '{{.State.Health.Status}}' app-app-1 2>/dev/null || echo none)
[ "$s" = healthy ] && exit 0
sleep 2
done
echo "nicht healthy geworden"; docker compose logs --tail 50 app; exit 1
EOF
- name: alte Images aufraeumen
run: ssh deploy@projekthost 'docker image prune -f --filter "until=168h"'
5 Über Tailscale, ohne offenen Port
Der GitHub-Runner joint kurz dein Tailnet – dann geht SSH über die MagicDNS-Adresse, Port 22 am Server bleibt zu.
- Tailscale-Admin →
→ Client mit Scope auth_keys + Tag
tag:ci. ID (Bezeichner)/Secret als Environment-Secrets. - ACL:
tag:cidarftag:server:22(nur SSH, nur solange nötig).
- uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:ci
# der Runner ist danach als ephemeral Node im Tailnet, weg nach dem Job
- run: ssh deploy@projekthost.tailXXXX.ts.net 'cd /opt/stacks/app && docker compose pull && docker compose up -d'
Am Server ist kein Port offen. Der Deploy-Weg existiert nur während des Jobs. Kein SSH-Brute-Force, keine dauerhaft gültigen Zugangsdaten am Server.
6 Statische Site
Kein Container nötig: bauen und die Dateien ausliefern.
jobs:
build-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci && npm run build # -> ./dist
# Variante A: Cloudflare Pages
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
command: pages deploy dist --project-name=nursude/app
# Variante B: rsync auf den nginx-Server (via Tailscale wie oben)
# - run: rsync -az --delete dist/ deploy@projekthost:/opt/sites/blog/
Das ist die automatisierte Fassung von deploy.ps1 aus
Geschützter Projekt-Host.
7 Datenbank-Migrationen
Schema-Änderungen laufen als eigener Schritt – vor dem Start der neuen App, damit die neue Version ein passendes Schema vorfindet.
- name: Migrationen
run: |
ssh deploy@projekthost bash -s <<'EOF'
set -e
docker run --rm --network web --env-file /opt/stacks/app/app.env \
ghcr.io/nursude/app:sha-${{ github.sha }} \
/app/bin/migrate up
EOF
- Nur additive Migrationen pro Deploy (Spalte hinzu, nicht umbenennen). Entfernen erst, wenn keine laufende Version die Spalte mehr braucht – zwei Deploys.
- Migrationstool bringt die App mit (Flyway, Liquibase,
alembic,prisma migrate, Rails …). Vor der Migration ein DB-Backup anstoßen. - Fehlgeschlagene Migration → Job rot, alte App läuft weiter (weil
compose upnoch nicht kam).
8 Rollback
cd /opt/stacks/app
docker images ghcr.io/nursude/app # welche Tags liegen lokal
echo "APP_TAG=sha-<alter-sha>" > .env
docker compose up -d
- GitHub-Weg: – deployt exakt denselben Stand nochmal.
- Alte Images einige Tage behalten (
image prune --filter "until=168h"), damit der Rollback ohne Registry-Zugriff geht. - Migration lässt sich meist nicht zurückrollen – darum additiv migrieren (Abschnitt 7). Notfall: DB-Restore aus dem Backup vor der Migration.
9 Vollständiger Workflow
name: deploy
on: { push: { branches: [ main ], tags: [ 'v*' ] } }
concurrency: { group: deploy-prod, cancel-in-progress: false }
jobs:
build:
runs-on: ubuntu-latest
permissions: { contents: read, packages: write }
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with: { registry: ghcr.io, username: '${{ github.actor }}', password: '${{ secrets.GITHUB_TOKEN }}' }
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/nursude/app:sha-${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:ci
- run: |
install -m700 -d ~/.ssh
printf '%s\n' "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- run: |
ssh deploy@projekthost.tailXXXX.ts.net bash -s <<'EOF'
set -euo pipefail
cd /opt/stacks/app
docker run --rm --network web --env-file app.env ghcr.io/nursude/app:sha-${{ github.sha }} /app/bin/migrate up
echo "APP_TAG=sha-${{ github.sha }}" > .env
docker compose pull && docker compose up -d --remove-orphans
for i in $(seq 1 30); do [ "$(docker inspect -f '{{.State.Health.Status}}' app-app-1)" = healthy ] && exit 0; sleep 2; done
docker compose logs --tail 50 app; exit 1
EOF
10 Betrieb & Sicherheit
- Actions auf SHA pinnen für Drittanbieter-Actions
(
uses: user/action@<40-hex> # v1.2.3) – ein Tag kann still umgebogen werden. Dependabot (.github/dependabot.yml,package-ecosystem: github-actions) hält sie aktuell. - Least privilege:
permissions:pro Job so eng wie möglich.GITHUB_TOKENnurpackages: write, wenn nötig. - Fork-PRs bekommen keine Secrets (Standard). Deploy nur auf
pushzumain/Tags, nie aufpull_request. - Secret Scanning + Push Protection im Repo an ().
- Deploy-Key rotieren, wenn jemand das Team verlässt oder ein Verdacht
besteht – ein neuer
ssh-keygen,authorized_keystauschen, Secret updaten. - Für echte Cloud-Provider (AWS/GCP): OIDC (OpenID Connect) statt langlebiger Keys
(
id-token: write+ Trust-Policy). Hetzner hat kein OIDC – darum der eng gefasste SSH-Key übers Tailnet.
+ Pull-Modell statt Push
Der Server holt sich neue Images selbst – dann liegen keine CI-Zugangsdaten für den Server irgendwo, und die CI braucht keinen Netzzugang zum Server.
| Weg | wie | Kontrolle |
|---|---|---|
| Watchtower | containrrr/watchtower pollt die Registry, aktualisiert Container mit passendem Label | grob (updatet, wenn ein neuer Tag da ist) |
| systemd-Timer | alle 5 min docker compose pull && up -d in /opt/stacks/app | mittel (du steuerst per Tag im .env, den Git setzt) |
| Webhook | GitHub-Webhook → winziger Empfänger am Server (webhook-Binary) → Deploy-Skript | fein (nur bei echtem Release, mit Signaturprüfung) |
| GitOps (k8s) | ArgoCD / Flux beobachten ein Repo mit den Manifesten | am feinsten – siehe k3s-Abschnitt |
+ Self-hosted Runner
Nur, wenn GitHub-hosted nicht reicht: ARM-Builds nativ, sehr große Builds, oder Zugriff auf ein privates Netz während des Builds.
services:
runner:
image: ghcr.io/actions/actions-runner:latest
restart: unless-stopped
environment:
- REPO_URL=https://github.com/nursude/app
volumes: [ /var/run/docker.sock:/var/run/docker.sock ]
# Registrierung per --ephemeral Token; nach jedem Job frisch
Ein Fork-PR kann bei einem öffentlichen Repo beliebigen Code auf dem Runner
ausführen. Self-hosted Runner nur für private Repos, immer
--ephemeral (ein Job, dann weg), am besten in einem eigenen Projekt / auf einem
Wegwerf-Server. Für k8s: actions-runner-controller.
+ k3s / Helm-Deploy
- uses: tailscale/github-action@v3
with: { oauth-client-id: '${{ secrets.TS_OAUTH_CLIENT_ID }}', oauth-secret: '${{ secrets.TS_OAUTH_SECRET }}', tags: tag:ci }
- run: |
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > kc.yaml
export KUBECONFIG=kc.yaml
helm upgrade --install app ./chart -n app \
--set image.tag=sha-${{ github.sha }} --wait --timeout 5m
kubectl -n app rollout status deploy/app
kubeconfig zeigt auf die API (Application Programming Interface) übers Tailnet
(https://projekthost.tailXXXX.ts.net:6443).
Ausführliches Beispiel: Bibliothek-Enterprise,
Abschnitt CI/CD (Continuous Delivery). Der GitOps-Weg (ArgoCD/Flux) hat die CI ganz raus aus dem Cluster: sie
schreibt nur den Tag in ein Manifest-Repo, der Cluster zieht selbst.
+ Jenkins statt GitHub Actions
Sinnvoll, wenn du schon Jenkins-Pipelines hast (z. B. im Bibliothek-Enterprise-Projekt), etwas komplett auf eigener Infrastruktur brauchst, oder Build-Orchestrierung, die über „push → build → deploy“ hinausgeht (Matrix über viele Services, geteilte Bibliotheken, Approval-Gates mit Rollen).
| GitHub Actions | Jenkins | |
|---|---|---|
| Betrieb | gehostet, 0 Pflege | eigener Server, Updates, Plugins, Backup |
| Pipeline | YAML (YAML Ain't Markup Language) im Repo | Jenkinsfile im Repo (Groovy, mächtiger & sperriger) |
| Secrets | Repo/Environment-Secrets | Credentials-Store (mit JCasC versionierbar) |
| Trigger | eingebaut | GitHub-Webhook → Jenkins (oder Polling) |
| Kosten | Free-Kontingent | der Server (~5–8 €/Mon) |
| wann | Standard für neue Projekte | vorhandene Jenkins-Welt, On-Prem-Pflicht, sehr komplexe Builds |
Jenkins auf Hetzner
services:
jenkins:
image: jenkins/jenkins:2.492-lts-jdk21
restart: unless-stopped
user: root # fuer Zugriff auf docker.sock
environment:
JAVA_OPTS: -Djenkins.install.runSetupWizard=false
CASC_JENKINS_CONFIG: /var/jenkins_conf/jenkins.yaml
volumes:
- jenkins:/var/jenkins_home
- ./jenkins.yaml:/var/jenkins_conf/jenkins.yaml:ro
- /var/run/docker.sock:/var/run/docker.sock
networks: [ web ]
volumes: { jenkins: {} }
networks: { web: { name: web, external: true } }
jenkins.deine-domainhinter Cloudflare Access – die Weboberfläche gehört nicht offen ins Netz.- JCasC (Configuration as Code, Plugin
configuration-as-code): Jenkins-Konfiguration inkl. Plugin-Liste, Nodes, Credentials-Bindings in einerjenkins.yamlim Repo – kein „handverdrahteter“ Server. - Agenten: für kleine Projekte reicht der Controller selbst („built-in node“).
Größer → Docker-Agenten (
docker-Plugin) oder ein zweiter Server als SSH-Agent. - Backup:
/var/jenkins_homeins Restic-Repo (Job-Historie, Credentials-Verschlüsselung liegt darin – oder mit JCasC nur die Config).
Dieselbe Pipeline als Jenkinsfile
pipeline {
agent any
options { disableConcurrentBuilds() } # wie "concurrency" bei Actions
environment {
IMAGE = "ghcr.io/nursude/app"
TAG = "sha-${GIT_COMMIT}"
}
stages {
stage('Build & Push') {
steps {
withCredentials([usernamePassword(credentialsId: 'ghcr',
usernameVariable: 'U', passwordVariable: 'P')]) {
sh '''
echo "$P" | docker login ghcr.io -u "$U" --password-stdin
docker build -t "$IMAGE:$TAG" .
docker push "$IMAGE:$TAG"
'''
}
}
}
stage('Deploy') {
when { branch 'main' }
steps {
withCredentials([sshUserPrivateKey(credentialsId: 'deploy-ssh', keyFileVariable: 'KEY')]) {
sh '''
ssh -i "$KEY" -o StrictHostKeyChecking=accept-new \
deploy@projekthost.tailXXXX.ts.net bash -s << EOF
set -euo pipefail
cd /opt/stacks/app
docker run --rm --network web --env-file app.env "$IMAGE:$TAG" /app/bin/migrate up
echo "APP_TAG=$TAG" > .env
docker compose pull && docker compose up -d --remove-orphans
EOF
'''
}
}
}
}
post {
failure { sh 'curl -fsS -d "Deploy $TAG fehlgeschlagen" ntfy.sh/alerts || true' }
}
}
Eine declarative pipeline – feste Struktur aus stages,
steps und post. Die Blöcke sind keine Groovy-Funktionen,
sondern von Jenkins vorgegebene Abschnitte:
| Konstrukt | Stelle | bewirkt |
|---|---|---|
agent any |
Kopf | Auf welchem Node die Pipeline laufen darf – any heißt: irgendeiner mit freier Kapazität. Bei einem Jenkins ohne zusätzliche Agenten ist das immer der Controller selbst. Ohne die Zeile weigert sich die Pipeline zu starten; sie ist Pflicht. |
options { disableConcurrentBuilds() } |
Kopf | Zwei Builds derselben Pipeline laufen nicht gleichzeitig; ein neuer wartet. Wichtig, weil die Deploy-Stufe unten Dateien auf dem Zielserver überschreibt – zwei gleichzeitige Läufe würden sich gegenseitig das .env unter den Füssen wegziehen. |
environment { IMAGE = … TAG = … } |
Kopf | Setzt Umgebungsvariablen für alle Stufen. GIT_COMMIT stellt Jenkins von sich aus bereit; die Schreibweise ${…} löst Groovy beim Lesen der Datei auf. Der Tag sha-<commit> ist der Grund, warum später nachvollziehbar bleibt, welcher Quellstand läuft. |
withCredentials([usernamePassword(…)]) |
Build | Holt ein in Jenkins hinterlegtes Zugangsdaten-Paar und legt es nur innerhalb dieses Blocks in die genannten Variablen. Zwei Eigenschaften machen es aus: die Werte stehen nirgends in der Datei, und Jenkins ersetzt sie in der Konsolenausgabe durch Sterne – ein versehentliches echo verrät also nichts. |
sh '''…''' (dreifach, einfach) |
beide Stufen | Die wichtigste Unterscheidung in einem Jenkinsfile. Bei dreifach-einfachen Anführungszeichen reicht Groovy den Text unangetastet an die Shell weiter – $P und $IMAGE löst also die Shell auf. Bei dreifach-doppelten (""") würde Groovy sie schon vorher ersetzen, und das Passwort stünde im Klartext im Skript, das Jenkins auf die Platte schreibt. Faustregel: in sh-Blöcken immer die einfachen. |
echo "$P" | docker login --password-stdin |
Build | Das Passwort kommt über die Standardeingabe statt als Argument. Argumente sind auf dem Rechner für jeden in der Prozessliste sichtbar (ps aux) – die Standardeingabe nicht. docker login -p <pw> warnt aus genau diesem Grund. |
when { branch 'main' } |
Deploy | Die Stufe läuft nur auf diesem Branch; auf allen anderen wird sie übersprungen und der Build gilt trotzdem als erfolgreich. So bauen und testen Feature-Branches, ohne dass sie auf den Server ausrollen. |
-o StrictHostKeyChecking=accept-new |
Deploy | Nimmt den Host-Key beim ersten Verbinden ungefragt an, meldet aber weiterhin, wenn sich ein bekannter Key ändert – anders als das grobe =no, das jede Warnung abschaltet. Nötig, weil in einem Build niemand eine Rückfrage beantworten kann. |
bash -s << EOF … EOF |
Deploy | Ein Here-Dokument: alles bis zur Zeile EOF wird über die Standardeingabe an die Remote-Shell gereicht, die es dank -s als Skript ausführt. So braucht es kein Skript auf dem Zielserver. Die abschließende Zeile muss ganz links stehen – eingerueckt erkennt die Shell sie nicht, das Here-Dokument endet nie und der Build hängt oder bricht mit unexpected end of file ab. (<<- erlaubt Einrückung, aber nur mit Tabulatoren, nicht mit Leerzeichen.) |
<< EOF ohne Anführungszeichen |
Deploy | Bedeutungstragend: so werden $IMAGE und $TAG auf dem Jenkins ersetzt, bevor der Text überhaupt losgeschickt wird – genau richtig, denn nur dort sind sie bekannt. Stünde << 'EOF' da, kämen die Variablennamen unverändert auf dem Server an und wären dort leer. |
docker compose up -d --remove-orphans |
Deploy | --remove-orphans räumt Container weg, die aus einer früheren Fassung der compose-Datei stammen und dort nicht mehr vorkommen. Ohne den Schalter laufen sie unbemerkt weiter – und hören dabei mitunter noch auf denselben Ports. |
post { failure { … } } |
Abschluss | Läuft nach allen Stufen, hier nur im Fehlerfall (daneben gibt es always, success, unstable). Das || true am Ende ist Absicht: ein nicht erreichbarer Benachrichtigungsdienst soll den Build nicht zusätzlich rot färben. |
- Credentials:
→
ghcr(Username+Token),deploy-ssh(SSH-Key). Per JCasC aus Umgebungsvariablen/Datei befüllbar. - GitHub-Webhook: Repo →
→
https://jenkins.…/github-webhook/. Plugin GitHub + Pipeline (oder Multibranch Pipeline für automatische Branch-Jobs). - Tailscale: den Jenkins-Container/Host ins Tailnet joinen (wie ein normaler Server, Tailscale-Anleitung), dann geht der SSH-Deploy über die MagicDNS-Adresse ohne offenen Port.
- Approval-Gate (wie das Environment bei Actions):
input message: 'nach Produktion?'als eigener Stage – wartet auf einen Klick.
Häufiges Muster: Jenkins baut & testet (viele Services, geteilte Groovy-Library, lange Integrationstests), GitHub Actions oder ein Pull-Mechanismus deployt. Oder umgekehrt. Beide können dasselbe GHCR und denselben Deploy-User nutzen.
Bibliothek-Enterprise per Jenkins auf OKD (OpenShift Kubernetes Distribution) – die vierstufige Jenkinsfile-Pipeline des Projekts deployt echt auf einen OKD-Single-Node: Jenkins baut 9 Images, pusht in OKDs clusterinterne Registry (kein GHCR, kein PAT) und rollt per Helm aus. Jede der 17 Fallen dokumentiert.
+ Kosten
Preise sind Richtwerte. Hetzner hat 2026 zweimal erhöht (zuletzt 15. Juni: CX/CAX +30–40 %, CPX/CCX über 100 %). Aktuelle Server-Preise, alle Nebenposten, ein Rechner und Beispielrechnungen stehen zentral in Kosten & Budget.
| Posten | Free-Kontingent | darüber |
|---|---|---|
| GitHub Actions (privat) | 2000 Min/Monat (Linux 1×) | ~0,008 $/Min |
| GitHub Actions (öffentliches Repo) | unbegrenzt | – |
| GHCR öffentliche Images | unbegrenzt | – |
| GHCR private Images | 500 MB (Megabyte) Speicher, 1 GB (Gigabyte) Transfer/Monat (Free-Konto) | ~0,25 $/GB Speicher |
| Self-hosted Runner (optional) | – | der Server, auf dem er läuft (~19,5 €/Mon CPX22, ARM CAX11 ~6) |
| Jenkins (optional, Abschnitt 14) | – | eigener Server ~5–8 €/Mon + Pflege |
Für ein privates Hobby-Repo mit ein paar
Deploys am Tag: praktisch immer im Free-Kontingent. Build-Cache (type=gha) hält
die Minuten niedrig.