---
title: 'Forgejo Actions Runner für self-hosted CI/CD einrichten'
description: 'Wie ich manuelle SSH-Deploys durch eine Push-to-Deploy-Pipeline mit einem self-hosted Forgejo Actions Runner auf demselben VPS ersetzt habe.'
pubDate: 'Apr 22 2026'
category: de/technik
tags:
  - forgejo
  - ci
  - self-hosted
  - devops
  - podman
translationKey: forgejo-actions-runner
---

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:

```bash
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:

```bash
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`):

```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`:

```bash
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:

```bash
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

```ini
[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
```

```bash
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:

```yaml
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:

```bash
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.
