GitHub Actions Self-Hosted Runner auf VPS betreiben 2026

Warum Self-Hosted Runner auf einem VPS?

GitHub-hosted Runner sind bequem, aber sie haben drei harte Grenzen: Preis, Hardware und Kontrolle. Sobald deine Pipeline regelmäßig über 2.000 Minuten pro Monat läuft, wird die Abrechnung nach Minuten teuer. Ein 8-vCPU-Linux-Runner kostet bei GitHub Actions 0,032 USD pro Minute – bei 10.000 Minuten sind das 320 USD monatlich. Ein vergleichbarer VPS mit 8 vCPU liegt bei Hetzner oder Netcup eher bei 15 bis 25 Euro im Monat, und du bezahlst unabhängig von der Laufzeit.

Der zweite Punkt ist die Hardware. GitHub-hosted Runner sind generische Standard-VMs ohne AVX-512-Tuning, ohne dedizierte NVMe und ohne Kontrolle über die CPU-Generation. Für Builds mit großen Docker-Layern, Rust-Kompilierung oder Machine-Learning-Tests ist das oft der Flaschenhals. Auf einem dedizierten VPS (z. B. Hetzner CCX-Serie) bekommst du garantierte vCPU-Kerne statt geteilter Burst-Kerne.

Der dritte Punkt ist die Netzwerk- und Datenkontrolle. Self-Hosted Runner können auf interne Datenbanken, private Registries oder ein lokales Kubernetes-Cluster zugreifen, ohne dass du alles über das öffentliche Internet exponierst. Das ist besonders für Staging-Deployments und Integrationstests relevant.

Die Kehrseite: Du bist selbst für Updates, Sicherheit, Monitoring und Ausfälle verantwortlich. Dieser Guide zeigt dir den kompletten Weg von der VPS-Bestellung bis zum gehärteten, ephemeren Runner-Fleet.

VPS-Auswahl: Specs, Anbieter und Preise 2026

Für CI/CD zählt vor allem Single-Core-Leistung und schnelle NVMe-I/O. Viele kleine Kerne bringen bei npm- oder Maven-Builds weniger als wenige schnelle Kerne. Als Faustregel gilt: ein vCPU-Kern pro parallelem Job, plus 1 bis 2 GB RAM pro Job.

Anbieter / TypvCPURAMNVMePreis/Monat (ca.)
Hetzner CX222 (shared)4 GB40 GB3,79 €
Hetzner CPX314 (AMD shared)8 GB160 GB13,60 €
Hetzner CX428 (shared)16 GB160 GB16,40 €
Hetzner CCX234 (dedicated)16 GB80 GB24,90 €
Netcup VPS 2000 G118 (shared)8 GB160 GB11,99 €

Für die meisten Teams ist ein CX42 oder CPX31 der Sweet Spot. Bei sehr großen Repos mit parallelen Matrix-Builds lohnt sich die dedizierte CCX-Serie, weil du dort keine Steal-Time durch Nachbarn hast. Achte auf unbegrenzten Traffic oder mindestens 20 TB Inklusivvolumen – Docker-Pulls und Cache-Uploads summieren sich schnell.

Wichtig: Wähle den Standort nahe an deinen Deploy-Zielen und an den GitHub-Servern. GitHub betreibt Actions-Infrastruktur primär in Azure-Regionen in den USA und Europa. Ein VPS in Falkenstein oder Nürnberg liefert typischerweise 15–30 ms Latenz zu api.github.com – völlig ausreichend, da der Runner-Client die Jobs pollt.

Server vorbereiten: Ubuntu 24.04 LTS härten

Ich gehe von einem frischen Ubuntu 24.04 LTS aus. Erste Schritte als root:

apt update && apt upgrade -y
apt install -y curl git jq unzip build-essential ca-certificates gnupg lsb-release ufw
timedatectl set-timezone Europe/Berlin

# Swap anlegen (wichtig bei 4 GB RAM)
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab

Dann Docker installieren – die meisten modernen Pipelines brauchen es ohnehin:

install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Lege einen dedizierten, unprivilegierten Benutzer für den Runner an und gib ihm Docker-Rechte. Der Docker-Socket ist faktisch root-äquivalent – deshalb niemals den Runner als root laufen lassen.

useradd -m -s /bin/bash runner
usermod -aG docker runner

Zum Schluss die Firewall. Der Runner benötigt keine eingehenden Ports – er baut ausschließlich ausgehende HTTPS-Verbindungen auf.

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw enable

Runner installieren und registrieren

Als Benutzer runner anmelden und das aktuelle Runner-Paket herunterladen. Prüfe vorher die neueste Version im actions/runner-Repository – zum Redaktionszeitpunkt war 2.321.0 aktuell.

su - runner
mkdir -p ~/actions-runner && cd ~/actions-runner
RUNNER_VERSION=2.321.0
curl -o actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz -L \
  https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz
tar xzf ./actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz

Für die Registrierung brauchst du ein Token. Das lässt sich über die GitHub-API mit einem Personal Access Token (Scope repo bzw. administration) erzeugen:

curl -sX POST -H "Authorization: Bearer $GH_PAT" \
  -H "Accept: application/vnd.github+json" \
  https://api.github.com/repos/OWNER/REPO/actions/runners/registration-token | jq -r .token

Jetzt konfigurierst du den Runner. Wichtig ist --unattended für Skripting und --replace, damit ein gleichnamiger alter Eintrag überschrieben wird:

./config.sh \
  --url https://github.com/OWNER/REPO \
  --token $REG_TOKEN \
  --name hetzner-cx42-01 \
  --labels self-hosted,linux,x64,hetzner-cx42,fast \
  --work _work \
  --unattended \
  --replace

Für Organisationen nutzt du --url https://github.com/ORG und den Endpoint /orgs/ORG/actions/runners/registration-token. Org-weite Runner sind meist sinnvoller, weil du sie über Runner-Groups auf Repositories einschränken kannst.

Runner als systemd-Service betreiben

Ein per ./run.sh gestarteter Runner stirbt bei SSH-Trennung oder Reboot. Nutze deshalb den mitgelieferten svc.sh-Wrapper, der eine systemd-Unit erzeugt:

sudo ./svc.sh install runner
sudo ./svc.sh start
sudo ./svc.sh status

Die Unit heißt nach dem Muster actions.runner.OWNER-REPO.hetzner-cx42-01.service. Prüfen mit:

systemctl status actions.runner.OWNER-REPO.hetzner-cx42-01.service
journalctl -u actions.runner.OWNER-REPO.hetzner-cx42-01.service -f

Ergänze in der Unit-Datei unter /etc/systemd/system/ ein paar Ressourcen-Limits, damit ein entgleister Build nicht den ganzen VPS lahmlegt:

[Service]
MemoryMax=12G
CPUQuota=750%
TasksMax=4096

Nach Änderungen sudo systemctl daemon-reload && sudo systemctl restart actions.runner.* ausführen. Bei mehreren Runnern auf einer Maschine lohnt sich ein Templating-Skript, das Name, Labels und Arbeitsverzeichnis pro Instanz setzt.

Mehrere Runner, Labels und Runner-Groups

Ein einzelner Runner verarbeitet genau einen Job gleichzeitig. Für parallele Workflows betreibst du entweder mehrere Runner-Prozesse auf demselben VPS (jeweils eigenes Verzeichnis und eigener Service) oder skalierst horizontal über mehrere VPS.

Labels sind der Schlüssel für gezieltes Routing. Vergib sprechende Labels wie fast, gpu, arm64 oder internal-network und referenziere sie im Workflow:

jobs:
  test:
    runs-on: [self-hosted, linux, x64, fast]

Runner-Groups (nur auf Org- und Enterprise-Ebene) erlauben dir, den Zugriff auf bestimmte Repositories zu beschränken. Das ist die wichtigste Absicherung gegen ungewollte Job-Ausführung aus fremden Repos. Lege mindestens zwei Groups an: trusted für Produktions-Deployments und sandbox für experimentelle Repos.

Für große Setups lohnt sich Actions Runner Controller (ARC) auf Kubernetes oder die neueren GitHub Actions Runner Scale Sets. Damit skalierst du Runner-Pods bedarfsgesteuert auf null herunter und zahlst nur, wenn Jobs laufen. Für ein einzelnes Team mit einem VPS ist das Overkill – der manuelle Ansatz ist günstiger und transparenter.

Docker, Docker-in-Docker und Container-Jobs

Da der Runner direkt auf der VM läuft, hat er nativen Zugriff auf den Docker-Daemon. Container-Jobs funktionieren trotzdem, aber du musst den Socket in den Job-Container mounten:

jobs:
  build:
    runs-on: [self-hosted, linux, x64]
    container:
      image: node:22-bookworm
      options: --user root -v /var/run/docker.sock:/var/run/docker.sock
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

Der Socket-Mount ist praktisch, aber ein Sicherheitsrisiko: Jeder Job kann damit beliebige Container mit Root-Rechten starten. Wenn du untrusted Code ausführst, nutze stattdessen Rootless Docker oder einen Docker-in-Docker-Sidecar mit eigenem Daemon.

Für BuildKit-Beschleunigung aktiviere den docker-container-Driver und einen persistenten Build-Cache:

docker buildx create --name builder --driver docker-container --use
docker buildx inspect --bootstrap

Ein --cache-from type=local,src=/home/runner/.buildx-cache plus --cache-to type=local,dest=/home/runner/.buildx-cache-new,mode=max reduziert Build-Zeiten bei großen Images oft um 60 bis 80 Prozent. Das ist der größte Einzelvorteil gegenüber GitHub-hosted Runnern, die bei jedem Job bei null anfangen.

Caching und Build-Performance

Persistenter Cache ist der Killer-Feature von Self-Hosted Runnern. Während GitHub-hosted Runner bei jedem Job eine leere VM bekommen, bleibt dein Cache auf der NVMe liegen.

- uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      ~/.cache/pip
      ~/.cargo/registry
      ~/.m2/repository
    key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json', '**/Cargo.lock') }}
    restore-keys: |
      ${{ runner.os }}-

Für kompilierte Sprachen bringt ein Compiler-Cache mehr als der Dependency-Cache. Bei Rust ist sccache mit S3-Backend oder lokalem Verzeichnis Pflicht, bei C/C++ ccache, bei Java der Gradle Build Cache.

Setze außerdem actions/checkout@v4 mit fetch-depth: 1, außer du brauchst die Git-Historie. Bei monorepo-artigen Repos kann ein flacher Clone mehrere Sekunden sparen.

Sicherheit: Ephemeral Runner, OIDC und Netzwerk-Isolation

GitHub selbst warnt davor, Self-Hosted Runner in öffentlichen Repositories einzusetzen. Der Grund: Pull Requests von Forks können beliebigen Code auf deiner Maschine ausführen. Wenn du das trotzdem brauchst, gibt es nur einen vertretbaren Weg – ephemere Runner, die nach genau einem Job zerstört werden.

./config.sh --url https://github.com/OWNER/REPO \
  --token $REG_TOKEN \
  --name ephemeral-$RANDOM \
  --ephemeral \
  --unattended --replace

Der Runner meldet sich nach dem Job automatisch ab. Du kombinierst das mit einem Skript, das den Container oder die VM danach neu erstellt. Auf einem VPS lässt sich das mit einem systemd-Timer oder einem kleinen Go-/Python-Wrapper realisieren, der den Runner-Prozess nach jedem Job neu startet.

Für Cloud-Deployments solltest du keine langfristigen Zugangsdaten auf dem VPS speichern. Nutze OIDC-Federation:

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/github-actions
      aws-region: eu-central-1

Damit erhält der Job temporäre Credentials, die nach der Laufzeit verfallen. Ein kompromittierter Runner hat so keinen Zugriff auf dauerhafte Secrets. Ergänzend: Secrets niemals in ~/.env oder Shell-History ablegen, sondern ausschließlich über GitHub Secrets und Masking.

Netzwerkseitig gilt: Der VPS sollte keine eingehenden Verbindungen außer SSH akzeptieren. Wenn der Runner auf interne Systeme zugreifen muss, spanne ein WireGuard-Netz auf und route nur die nötigen Subnetze.

Monitoring, Updates und Wartung

Ein Runner, der stumm stirbt, kostet dich Stunden. Installiere node_exporter und cadvisor und scrape sie mit Prometheus. Die wichtigsten Metriken sind Disk-Füllstand (Docker-Images fressen schnell 100 GB), Load Average und die Anzahl wartender Jobs.

# Docker-Aufräumen per Cron
docker system prune -af --filter "until=168h"
docker builder prune -af --filter "until=168h"

Runner-Updates sind manuell. Der Ablauf: Service stoppen, neue Version entpacken, ./config.sh remove --token $REMOVE_TOKEN, neu konfigurieren, Service starten. Automatisiere das mit einem Ansible-Playbook oder einem kleinen Skript, das die Version aus der GitHub-API liest.

Logs liegen unter _diag/ im Runner-Verzeichnis. Bei hängenden Jobs hilft journalctl -u actions.runner.* -n 200. Richte einen Alert ein, wenn der Runner länger als 10 Minuten keinen Heartbeat sendet – in der GitHub-UI siehst du den Status unter Settings → Actions → Runners.

Plane außerdem regelmäßige Reboots ein. Kernel-Updates, Docker-Daemon-Neustarts und Speicherfragmentierung machen einen wöchentlichen Reboot zur günstigsten Wartungsmaßnahme überhaupt.

Kostenvergleich: GitHub-hosted vs. Self-Hosted

GitHub berechnet Linux-Runner ab 2026 mit 0,008 USD/Minute (2 vCPU), 0,016 USD (4 vCPU), 0,032 USD (8 vCPU) und 0,064 USD (16 vCPU). Inklusivminuten: 2.000 bei Free, 3.000 bei Pro/Team, 50.000 bei Enterprise Cloud. Öffentliche Repositories erhalten weiterhin unbegrenzte kostenlose Minuten auf Standard-Runnern.

Nutzung pro MonatGitHub-hostedSelf-Hosted VPSErsparnis
2 vCPU, 1.000 min8,00 USD~3,79 € (CX22)~55 %
4 vCPU, 5.000 min80,00 USD~6,80 € (CX32)~92 %
8 vCPU, 10.000 min320,00 USD~16,40 € (CX42)~95 %
16 vCPU, 20.000 min1.280,00 USD~50–60 € (CCX43)~96 %

Die Rechnung ignoriert allerdings deine Arbeitszeit. Rechne mit 4 bis 8 Stunden initialem Setup und danach 1 bis 2 Stunden Wartung pro Monat. Ab etwa 3.000 Minuten monatlicher Nutzung oder bei spezieller Hardware (GPU, ARM, viel RAM) kippt die Rechnung klar zugunsten von Self-Hosted.

Ein Hybrid-Modell ist oft optimal: Self-Hosted Runner für die schweren, regelmäßigen Builds, GitHub-hosted Runner als Fallback für seltene Matrix-Jobs oder wenn der VPS ausfällt. Definiere im Workflow runs-on: [self-hosted, linux, x64] und halte zusätzlich einen GitHub-hosted Workflow für Notfälle bereit.

FAQ

Braucht mein VPS offene Ports für den GitHub Actions Runner?

Nein. Der Runner-Client arbeitet rein ausgehend: Er pollt regelmäßig api.github.com und den Actions-Service über HTTPS (Port 443) nach neuen Jobs. Du musst lediglich ausgehenden Verkehr erlauben. Eingehend reicht SSH auf einem Nicht-Standard-Port, idealerweise abgesichert per Fail2ban und Key-Auth.

Kann ich Self-Hosted Runner in einem öffentlichen Repository nutzen?

Technisch ja, sicherheitstechnisch nur mit ephemeren Runnern und strikter Isolation. Fork-Pull-Requests können beliebigen Code ausführen. GitHub empfiehlt ausdrücklich, Self-Hosted Runner nicht für öffentliche Repos zu verwenden. Wenn es unvermeidbar ist: ephemerer Runner pro Job, kein Zugriff auf interne Netze, keine langlebigen Secrets, und der Runner läuft in einer Wegwerf-VM oder einem Container mit eingeschränkten Rechten.

Wie viele Runner kann ich auf einem VPS betreiben?

Als Faustregel einen Runner-Prozess pro vCPU, wenn die Jobs CPU-intensiv sind, und einen pro 2 GB RAM, wenn sie speicherhungrig sind. Auf einem 8-vCPU/16-GB-VPS laufen typischerweise 4 bis 6 parallele Runner zuverlässig. Beobachte Load Average und docker stats – ab Load 1,0 pro Kern warten die Jobs länger als sie rechnen.

Was passiert, wenn der VPS oder Runner ausfällt?

Laufende Jobs brechen ab und werden in der GitHub-UI als fehlgeschlagen markiert. Der Runner verschwindet nach etwa 10 bis 15 Minuten aus der Runner-Liste, sofern kein Heartbeat mehr eintrifft. Richte einen zweiten VPS als Standby ein oder definiere einen Fallback-Workflow auf GitHub-hosted Runnern, damit kritische Deployments nicht blockiert werden.

Funktionieren auch ARM64- oder GPU-Runner self-hosted?

Ja, und das ist einer der stärksten Gründe für Self-Hosted. GitHub bietet ARM-Runner nur eingeschränkt und GPU-Runner gar nicht. Ein Hetzner CAX21 (ARM64, 4 vCPU, 8 GB) kostet rund 7 Euro im Monat und beschleunigt Multi-Arch-Builds erheblich. Für GPU-Workloads mietest du einen dedizierten Server mit NVIDIA-Karte und installierst den Runner wie oben beschrieben – achte nur darauf, dass der Benutzer runner in der video- und render-Gruppe ist.