Forgejo Actions Runner für self-hosted CI/CD einrichten

Kategorie: Technik

Schlagwörter: forgejo, ci, self-hosted, devops, podman


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 einen podman-compose@ systemd-Service) hostet.
  • Forgejo: v11 LTS, rootless, läuft unter einem eigenen git System-User.
  • Ziel: bei jedem Push auf main das 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:

  1. Eine .forgejo/workflows/deploy.yml mit runs-on: self-hosted ins Repo packen.
  2. Projekt-spezifische Secrets unter den Actions-Settings des Repos anlegen.
  3. Falls das Projekt einen eigenen systemd-Service hat, /etc/sudoers.d/forgejo-runner-deploy um eine Zeile systemctl 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.

Reaktionen

1 Like