← Übersicht  ·  Skripte & Dateien  ·  cloud-host · Betrieb

CI/CD nach Hetzner

Von git push zum laufenden Dienst: GitHub Actions baut das Container-Image, schiebt es nach GHCR (GitHub Container Registry) und deployt per SSH (Secure Shell) – über Tailscale, also ohne einen offenen Port am Server. Mit Secrets in Environments (mit Freigabe), Datenbank-Migrationen, Rollback, dem Pull-Modell als Alternative, self-hosted Runner, k3s/Helm-Deploy – und Jenkins für alle, die schon eine Pipeline-Welt haben.

Stand: 30. August 2026 GitHub Actions + GHCR 0 € (Free-Kontingent)

Platzhalter einsetzen – nur im Browser

Voraussetzung: ein eingerichteter Server – einmaliger Aufbau in Server-Basis, wegwerfen & identisch neu in Snapshot & Restore. Was die Bausteine hier kosten: Kosten & Budget.

Ablauf & Entscheidungen

pushgit push auf main (oder ein Tag v1.2.3) startet den Workflow
buildRunner baut das Image, taggt mit sha + latest, schiebt nach ghcr.io/nursude/app
FreigabeEnvironment production: optionaler manueller Klick / Wartezeit / erlaubte Branches
deployRunner joint dein Tailnet (ephemeral Key) → SSH zum Server → compose pull && up -d
checkHealthcheck der neuen Version. Schlägt er fehl → Workflow rot, alte Version läuft weiter
FrageEmpfehlung
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

Datei · .github/workflows/deploy.yml (Teil 1: build)
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
Was hier passiert

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.

KonstruktStellebewirkt
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@v6
context: .
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=gha
cache-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 ~/.ssh
chmod 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.
Paket öffentlich oder privat

Nach dem ersten Push liegt das Paket unter github.com/<owner>?tab=packages. 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.

ARM-Server?

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.

Server · als 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 der docker-Gruppe – das reicht für docker compose, kein sudo.
  • Noch enger: den Key auf einen Befehl beschränken (command="/opt/bin/deploy $SSH_ORIGINAL_COMMAND",restrict in authorized_keys).
  • Host-Key vorab kennen: ssh-keyscan projekthost ausführen und das Ergebnis als Secret SSH_KNOWN_HOSTS ablegen – dann kein StrictHostKeyChecking=accept-new nötig.

3 Secrets in GitHub

Ortwofür
Repository secrets
Settings Secrets and variables Actions
alles für alle Läufe (Build-Args ohne Bezug zur Umgebung)
Environment secrets
Settings Environments production
Deploy-Key, DB-Passwort, Tailscale-OAuth – nur der deploy-Job kommt dran, und nur nach der Freigabe
secrets.GITHUB_TOKENautomatisch, kurzlebig – GHCR-Push, Deployments-API. Kein eigenes Secret nötig.
  1. Environment production anlegen → Required reviewers (du selbst), optional Wait timer, Deployment branches: main und Tags.
  2. Environment-Secrets hinterlegen: SSH_KEY, SSH_KNOWN_HOSTS, TS_OAUTH_CLIENT_ID, TS_OAUTH_SECRET, App-Secrets.
  3. 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).

Laptop GitHub Actions ghcr.io Server Container git push auf main HTTPS oder SSH zu GitHub checkout + build push sha-<commit> HTTPS · Registry-API Cache: type=gha, mode=max SSH 22 über das Tailnet – kein offener Port WireGuard/UDP, von innen aufgebaut compose pull HTTPS zu ghcr.io up -d --remove-orphans healthy? – sonst Abbruch Das Secret verlässt nie GitHub: der Runner meldet sich selbst an der Registry an. Der Server bekommt nur den fertigen Tag zu sehen.
Der Server zieht das Image, er baut es nicht. Damit braucht er weder Quellcode noch Build-Werkzeuge noch Zugangsdaten zum Repository. Der Tag ist der Commit-Hash – das ist die einzige Stelle, an der der Bezug zwischen laufendem Container und Quellstand entsteht; latest wäre bequemer und würde genau diese Zuordnung zerstören.
Server · /opt/stacks/app/ (in Git, via deploy.ps1/rsync)
# 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 } }
Datei · deploy.yml (Teil 2: deploy)
  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.

  1. Tailscale-Admin → Settings OAuth (Open Authorization) clients → Client mit Scope auth_keys + Tag tag:ci. ID (Bezeichner)/Secret als Environment-Secrets.
  2. ACL: tag:ci darf tag:server:22 (nur SSH, nur solange nötig).
Datei · deploy.yml (im deploy-Job, vor dem SSH-Step)
      - 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'
Ergebnis

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.

Datei · .github/workflows/site.yml
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.

im deploy-Job, vor "compose up"
      - 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 up noch nicht kam).

8 Rollback

Server · sofort, von Hand
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: Actions letzter guter Lauf Re-run jobs – 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

Datei · .github/workflows/deploy.yml (ganz)
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_TOKEN nur packages: write, wenn nötig.
  • Fork-PRs bekommen keine Secrets (Standard). Deploy nur auf push zu main/Tags, nie auf pull_request.
  • Secret Scanning + Push Protection im Repo an (Settings Code security).
  • Deploy-Key rotieren, wenn jemand das Team verlässt oder ein Verdacht besteht – ein neuer ssh-keygen, authorized_keys tauschen, 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.

WegwieKontrolle
Watchtowercontainrrr/watchtower pollt die Registry, aktualisiert Container mit passendem Labelgrob (updatet, wenn ein neuer Tag da ist)
systemd-Timeralle 5 min docker compose pull && up -d in /opt/stacks/appmittel (du steuerst per Tag im .env, den Git setzt)
WebhookGitHub-Webhook → winziger Empfänger am Server (webhook-Binary) → Deploy-Skriptfein (nur bei echtem Release, mit Signaturprüfung)
GitOps (k8s)ArgoCD / Flux beobachten ein Repo mit den Manifestenam 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.

Datei · /opt/stacks/gh-runner/compose.yaml (auf einem Wegwerf-Server oder dem Projekt-Host)
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
Nie auf öffentlichen Repos

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

im deploy-Job (statt SSH + compose)
      - 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 ActionsJenkins
Betriebgehostet, 0 Pflegeeigener Server, Updates, Plugins, Backup
PipelineYAML (YAML Ain't Markup Language) im RepoJenkinsfile im Repo (Groovy, mächtiger & sperriger)
SecretsRepo/Environment-SecretsCredentials-Store (mit JCasC versionierbar)
TriggereingebautGitHub-Webhook → Jenkins (oder Polling)
KostenFree-Kontingentder Server (~5–8 €/Mon)
wannStandard für neue Projektevorhandene Jenkins-Welt, On-Prem-Pflicht, sehr komplexe Builds

Jenkins auf Hetzner

Datei · /opt/stacks/jenkins/compose.yaml
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-domain hinter 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 einer jenkins.yaml im 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_home ins Restic-Repo (Job-Historie, Credentials-Verschlüsselung liegt darin – oder mit JCasC nur die Config).

Dieselbe Pipeline als Jenkinsfile

Datei · Jenkinsfile (declarative pipeline)
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' }
  }
}
Was hier passiert

Eine declarative pipeline – feste Struktur aus stages, steps und post. Die Blöcke sind keine Groovy-Funktionen, sondern von Jenkins vorgegebene Abschnitte:

KonstruktStellebewirkt
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 << EOFEOF 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: Manage Jenkins Credentialsghcr (Username+Token), deploy-ssh (SSH-Key). Per JCasC aus Umgebungsvariablen/Datei befüllbar.
  • GitHub-Webhook: Repo → Settings Webhookshttps://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.
Kombinieren

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.

Real durchgezogen

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.

PostenFree-Kontingentdarüber
GitHub Actions (privat)2000 Min/Monat (Linux 1×)~0,008 $/Min
GitHub Actions (öffentliches Repo)unbegrenzt
GHCR öffentliche Imagesunbegrenzt
GHCR private Images500 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.

⌂ Cockpit