Forgejo Actions Runner für self-hosted CI/CD einrichten
Wie ich manuelle SSH-Deploys durch eine Push-to-Deploy-Pipeline mit einem self-hosted Forgejo Actions Runner auf demselben VPS ersetzt habe.
Kategorie: Technik
Nachdem ich meine Git-Repos von GitHub auf eine self-hosted Forgejo-Instanz umgezogen hatte, war der nächste logische Schritt, das Deployment von meinem Laptop wegzubekommen. Statt lokal ./scripts/deploy.sh auszuführen und zu hoffen, dass nichts uncommittet ist, sollte git push den Build anstoßen und den Container automatisch ausrollen.
Dieser Beitrag dokumentiert das komplette Setup: Forgejo Actions Runner auf demselben VPS installieren, an einen Workflow koppeln und Secrets sauber aus dem Repo halten.
Das Setup
- VPS: eine Debian-Maschine, die sowohl Forgejo (rootless Podman-Container) als auch die Astro-Website (
/opt/websites/adrian-altner.de, verwaltet über einenpodman-compose@systemd-Service) hostet. - Forgejo: v11 LTS, rootless, läuft unter einem eigenen
gitSystem-User. - Ziel: bei jedem Push auf
maindas Production-Image neu bauen und den Service neu starten — alles auf derselben Maschine.
Warum ein eigener Runner-User
Der Runner führt beliebigen Code aus Workflow-Dateien aus. Ihn als git-User laufen zu lassen (der Zugriff auf Forgejos Datenbank und jedes Repo hat) wäre keine gute Idee. Ich habe einen separaten System-User mit abgeschottetem Home-Verzeichnis angelegt:
sudo useradd --system --create-home \
--home-dir /var/lib/forgejo-runner \
--shell /bin/bash forgejo-runner
Dieser User bekommt standardmäßig kein sudo — wir erteilen gezielt nur die Rechte, die der Deploy tatsächlich braucht.
Runner-Binary installieren
Der Runner wird als einzelnes statisches Binary aus Forgejos eigener Registry verteilt. Ich hole mir das neueste Release programmatisch:
LATEST=$(curl -s https://code.forgejo.org/api/v1/repos/forgejo/runner/releases \
| grep -oE '"tag_name":"[^"]+"' | head -1 | cut -d'"' -f4)
VER="${LATEST#v}"
cd /tmp
curl -L -o forgejo-runner \
"https://code.forgejo.org/forgejo/runner/releases/download/${LATEST}/forgejo-runner-${VER}-linux-amd64"
chmod +x forgejo-runner
sudo mv forgejo-runner /usr/local/bin/
Ein kurzes forgejo-runner --version bestätigt v12.9.0 — die aktuelle Major-Version, kompatibel mit Forgejo v10, v11 und allem darüber.
Actions in Forgejo aktivieren
Actions sind bei einer Forgejo-Instanz standardmäßig aus. Die minimale Konfiguration kommt in die app.ini (bei mir im rootless-Container-Volume unter /home/git/forgejo-data/custom/conf/app.ini):
[actions]
ENABLED = true
DEFAULT_ACTIONS_URL = https://code.forgejo.org
DEFAULT_ACTIONS_URL ist wichtig, weil der GitHub Actions Marketplace nicht direkt erreichbar ist — Forgejo pflegt eigene Mirrors der gängigen Actions wie actions/checkout unter code.forgejo.org/actions/*. Nach einem Container-Restart taucht das Verzeichnis actions_artifacts in den Logs auf.
Runner registrieren
Runner können auf ein einzelnes Repo, einen User-Account oder die gesamte Instanz registriert werden. Ich habe mit einer Repo-Registrierung für meine Website angefangen und dann auf User-Scope umgestellt, damit derselbe Runner alle meine Repos bedienen kann, ohne sich neu registrieren zu müssen.
Der Registrierungstoken kommt aus Benutzer-Einstellungen → Actions → Runner → Neuen Runner erstellen:
sudo -iu forgejo-runner /usr/local/bin/forgejo-runner register \
--no-interactive \
--instance https://git.altner.cloud \
--token <REGISTRATION_TOKEN> \
--name arcturus-runner \
--labels "self-hosted:host"
Das Label self-hosted:host bedeutet: “Jobs mit Label self-hosted laufen direkt auf dem Host”. Kein Container-Runtime für den Runner selbst nötig — Podman haben wir ja schon für die Anwendung.
Umstellung eines bestehenden Runners von Repo- auf User-Scope: Service stoppen, alten Runner-Eintrag in der Forgejo-UI löschen, /var/lib/forgejo-runner/.runner lokal entfernen, neuen User-Level-Token holen, neu registrieren, Service starten. Gleiches Binary, anderer Scope.
Docker-Abhängigkeit abschalten
Beim ersten Start hat sich der Runner geweigert zu laufen:
Error: daemon Docker Engine socket not found and docker_host config was invalid
Auch wenn man nur das Host-Label nutzt, prüft der Runner beim Start auf einen Docker-Socket. Da der Server nur rootless Podman hat, habe ich eine Config-Datei erzeugt und den Docker-Check explizit deaktiviert:
sudo -iu forgejo-runner /usr/local/bin/forgejo-runner generate-config \
> /tmp/runner-config.yaml
sudo mv /tmp/runner-config.yaml /var/lib/forgejo-runner/config.yaml
sudo chown forgejo-runner:forgejo-runner /var/lib/forgejo-runner/config.yaml
sudo -iu forgejo-runner sed -i \
-e 's|docker_host: .*|docker_host: "-"|' \
-e 's| labels: \[\]| labels: ["self-hosted:host"]|' \
/var/lib/forgejo-runner/config.yaml
Systemd-Service
[Unit]
Description=Forgejo Actions Runner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=forgejo-runner
Group=forgejo-runner
WorkingDirectory=/var/lib/forgejo-runner
ExecStart=/usr/local/bin/forgejo-runner --config /var/lib/forgejo-runner/config.yaml daemon
Restart=on-failure
RestartSec=5s
NoNewPrivileges=false
ProtectSystem=full
ProtectHome=read-only
ReadWritePaths=/var/lib/forgejo-runner
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now forgejo-runner
Nur die nötigen sudo-Rechte
Der Deploy-Step muss ein Podman-Image bauen und den systemd-Service neu starten, der es ausführt. Beides braucht Root. Statt dem Runner-User breites sudo zu geben, habe ich eine eng gefasste Allowlist unter /etc/sudoers.d/forgejo-runner-deploy angelegt:
forgejo-runner ALL=(root) NOPASSWD: /usr/bin/podman build *, \
/usr/bin/podman container prune *, \
/usr/bin/podman image prune *, \
/usr/bin/podman builder prune *, \
/usr/bin/systemctl restart podman-compose@adrian-altner.de.service, \
/usr/bin/rsync *
visudo -cf prüft die Syntax, bevor man sich versehentlich komplett aus sudo aussperrt.
Der Workflow
Workflows liegen unter .forgejo/workflows/*.yml. Der Deploy-Flow macht dasselbe wie mein altes Shell-Skript, nur ohne SSH:
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: self-hosted
env:
DEPLOY_DIR: /opt/websites/adrian-altner.de
steps:
- uses: actions/checkout@v4
- name: Sync to deploy directory
run: |
sudo rsync -a --delete \
--exclude='.env' \
--exclude='.env.production' \
--exclude='.git/' \
--exclude='node_modules/' \
./ "${DEPLOY_DIR}/"
- name: Build image
run: |
cd "${DEPLOY_DIR}"
sudo podman build \
--build-arg WEBMENTION_TOKEN="${{ secrets.WEBMENTION_TOKEN }}" \
-t localhost/adrian-altner.de:latest .
- name: Restart service
run: sudo systemctl restart podman-compose@adrian-altner.de.service
- name: Prune
run: |
sudo podman container prune -f 2>/dev/null || true
sudo podman image prune --external -f 2>/dev/null || true
sudo podman image prune -f 2>/dev/null || true
sudo podman builder prune -af 2>/dev/null || true
Secrets bleiben in Forgejo
Alles Sensible — in meinem Fall API-Tokens für webmention.io und webmention.app — liegt in Settings → Actions → Secrets und wird als ${{ secrets.NAME }} in den Job injiziert. Forgejo speichert sie verschlüsselt, und Workflow-Logs maskieren die Werte automatisch. Die Tokens werden an genau zwei Stellen referenziert: in der CI-Workflow-Datei (committet) und im verschlüsselten Forgejo-Store (nie im Repo).
Der Build-Time-Token wird als ARG in den Container gereicht, nur während des Build-Stages benutzt und ist im finalen Runtime-Image nicht enthalten — ein schnelles podman run --rm <image> env | grep -i webmention bestätigt das.
Der eine Stolperstein: Node auf dem Host
Der erste echte Workflow-Lauf ist sofort gestorben mit:
Cannot find: node in PATH
actions/checkout@v4 ist eine JavaScript-basierte Action. Bei einem Runner mit Host-Label läuft sie direkt auf dem VPS und braucht einen Node-Interpreter im PATH. Ein apt install später war der Runner zufrieden:
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
sudo systemctl restart forgejo-runner
Ergebnis
Von einem kalten git push origin main bis zur komplett durchgelaufenen Pipeline — Checkout, rsync, Podman-Build, systemd-Restart, Prune, Webmention-Pings — vergehen etwa 1 Minute 15 Sekunden. Keine SSH-Keys zu rotieren, kein Laptop involviert, kein Mysterium über den Stand der Live-Version.
Der Runner selbst belegt im Idle rund 5 MB RAM und pollt Forgejo alle zwei Sekunden auf neue Jobs. Der Ressourcen-Overhead ist vernachlässigbar verglichen mit dem Komfort von Push-to-Deploy auf Infrastruktur, die mir komplett gehört.
Runner für neue Projekte wiederverwenden
Weil der Runner auf User-Scope registriert ist, reduziert sich das Anhängen von CI an ein neues Repo auf drei Schritte:
- Eine
.forgejo/workflows/deploy.ymlmitruns-on: self-hostedins Repo packen. - Projekt-spezifische Secrets unter den Actions-Settings des Repos anlegen.
- Falls das Projekt einen eigenen systemd-Service hat,
/etc/sudoers.d/forgejo-runner-deployum eine Zeilesystemctl restart <neuer-service>erweitern. Sonst muss auf dem Server nichts geändert werden.
Die einmaligen Infrastrukturkosten — User-Account, Binary, Config, systemd-Unit, Node-Runtime, sudoers — amortisieren sich über jedes weitere Projekt.
