# Gitea + Act Runner — Single-File Compose (QNAP Container Station)

Eine einzige `docker-compose.yml` zum Direkt-Einfügen in Container Station. Persistente Daten liegen unter `/share/Container/gitea/` (stabiler Pfad, überlebt Stack-Recreate). Runner-Config wird beim Start inline generiert.

## Vorbereitung auf der QNAP (einmalig)

1. **Shared Folder `Container` existieren lassen** — falls nicht vorhanden, in File Station → New Shared Folder → Name `Container`.

2. **Per SSH die Daten-Verzeichnisse anlegen** mit den korrekten Ownerships für die Container-UIDs:

```bash
sudo mkdir -p /share/Container/gitea/gitea-data \
              /share/Container/gitea/postgres-data \
              /share/Container/gitea/act-runner-data

# Postgres-Container läuft als UID 70
sudo chown -R 70:70 /share/Container/gitea/postgres-data

# Gitea läuft intern als git user (UID 1000)
sudo chown -R 1000:1000 /share/Container/gitea/gitea-data /share/Container/gitea/act-runner-data
```

3. **Registrierungs-Token-Ablauf (wie vorher):** Erst Gitea + DB deployen (act_runner-Block auskommentiert oder mit leerem Token). Dann im Web-UI Runner-Token erzeugen → als Env-Var im Stack hinterlegen → act_runner deployen.

## docker-compose.yml

```yaml
version: "3"

services:
  gitea:
    image: gitea/gitea:latest
    container_name: gitea
    environment:
      - GITEA__database__DB_TYPE=postgres
      - GITEA__database__HOST=db:5432
      - GITEA__database__NAME=gitea
      - GITEA__database__USER=gitea
      - GITEA__database__PASSWD=UGi2VZA7SgYGov
      - GITEA__server__DOMAIN=gitea.hartmut-noerenberg.com
      - GITEA__server__SSH_DOMAIN=gitea.hartmut.noerenberg.com
      - GITEA__server__ROOT_URL=https://gitea.hartmut-noerenberg.com/
      - GITEA__server__SSH_PORT=2222
      - GITEA__server__HTTP_PORT=3000
      # Gitea Actions aktivieren
      - GITEA__actions__ENABLED=true
      - GITEA__actions__DEFAULT_ACTIONS_URL=https://github.com
      - GITEA__actions__LOG_COMPRESSION=zstd
    restart: unless-stopped
    networks:
      - gitea
      - nginxproxy_nginxintern
    volumes:
      - /share/Container/gitea/gitea-data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "3000:3000"
      - "2222:22"
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    container_name: gitea-db
    restart: unless-stopped
    # Geben wir Postgres großzügig Zeit für sauberen Shutdown beim Stop/Replace.
    # Ohne diesen Grace muss beim nächsten Start Crash-Recovery laufen
    # (fsync über alle Files) — auf HDD-backed QNAP-Storage dauert das
    # schnell 5-10 Minuten und blockt Gitea beim Start.
    # 120s ist bewusst großzügig: bei viel WAL-Write (CI-Läufe mit Artefakten)
    # kann auch ein sauberer Shutdown 30-60s dauern.
    stop_grace_period: 120s
    environment:
      - POSTGRES_USER=gitea
      - POSTGRES_PASSWORD=UGi2VZA7SgYGov
      - POSTGRES_DB=gitea
    networks:
      - gitea
    volumes:
      - /share/Container/gitea/postgres-data:/var/lib/postgresql/data

  act_runner:
    image: gitea/act_runner:latest
    container_name: gitea-act-runner
    restart: unless-stopped
    depends_on:
      - gitea
    # WICHTIG: dns am act_runner-Container selbst setzen, NICHT nur in
    # container.options (das wirkt nur auf Job-Sub-Container). act_runner
    # clont `actions/checkout` etc. aus seinem eigenen Prozess heraus nach
    # /data/workflows — dafür zählt seine eigene /etc/resolv.conf. Ohne
    # diese Zeilen steht dort 127.0.0.11 (Dockers embedded DNS im
    # gitea_gitea-Netz), was auf QNAP unzuverlässig forwarded ("server
    # misbehaving") und jedes action-Clone killt.
    dns:
      - 8.8.8.8
      - 1.1.1.1
    dns_search: []
    environment:
      - GITEA_INSTANCE_URL=http://gitea:3000
      - GITEA_RUNNER_REGISTRATION_TOKEN=218iFl8s3a6uJxntyoobzu24pQJBGGVIWmdtJbXh
      - GITEA_RUNNER_NAME=qnap-runner-1
      # catthehacker/ubuntu:act-latest statt node:20-bookworm, weil sonst
      # `docker`-CLI in Job-Containern fehlt und Workflows wie release-image.yml
      # (docker login/buildx) mit "docker: command not found" scheitern.
      - GITEA_RUNNER_LABELS=ubuntu-latest:docker://catthehacker/ubuntu:act-latest,ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04
      - CONFIG_FILE=/config.yaml
    networks:
      - gitea
    volumes:
      - /share/Container/gitea/act-runner-data:/data
      - /var/run/docker.sock:/var/run/docker.sock
    entrypoint:
      - /bin/sh
      - -c
      - |
        cat > /config.yaml <<'EOF'
        log:
          level: info
        runner:
          file: /data/.runner
          capacity: 4
          timeout: 3h
          insecure: false
          fetch_timeout: 5s
          fetch_interval: 2s
        cache:
          enabled: true
          dir: /data/cache
        container:
          network: gitea_gitea
          privileged: false
          # --dns: Docker's embedded DNS auf 127.0.0.11 im gitea_gitea-Netz
          # forwarded auf QNAP leider unzuverlässig ("server misbehaving"),
          # was jedes `git clone https://github.com/actions/checkout` killt.
          # Expliziter Upstream-DNS im Job-Container umgeht das Problem.
          options: "--dns 8.8.8.8 --dns 1.1.1.1"
          workdir_parent: /workspace
          valid_volumes:
            - /var/run/docker.sock
        host:
          workdir_parent: /data/workflows
        EOF
        if [ ! -f /data/.runner ]; then
          act_runner register --no-interactive \
            --instance "$$GITEA_INSTANCE_URL" \
            --token "$$GITEA_RUNNER_REGISTRATION_TOKEN" \
            --name "$$GITEA_RUNNER_NAME" \
            --labels "$$GITEA_RUNNER_LABELS" \
            --config /config.yaml
        fi
        exec act_runner daemon --config /config.yaml

networks:
  gitea:
    external: false
  nginxproxy_nginxintern:
    external: true
```

## Deploy-Ablauf in Container Station

**Phase 1: Gitea + DB (ohne Runner)**

1. Container Station → **Applications → Create**
2. Application Name: `gitea`
3. Obige YAML einfügen, **aber den gesamten `act_runner`-Service-Block temporär auskommentieren** (mit `#` vor jeder Zeile, oder einfach löschen und später wieder einfügen)
4. Create + Start
5. Browser: `https://gitea.hartmut-noerenberg.com` → Admin-User anlegen, Repos/Orgs einrichten

**Phase 2: Runner hinzufügen**

6. In Gitea als Admin: **Site Administration → Actions → Runners → Create new Runner** → Token kopieren
7. In Container Station: Stack `gitea` → **Edit** → `act_runner`-Block wieder einfügen → unter **Environment Variables** hinzufügen:
   - Key: `GITEA_RUNNER_REGISTRATION_TOKEN`
   - Value: `<Token aus Schritt 6>`
8. Stack neu deployen
9. Logs prüfen:
   ```bash
   docker logs -f gitea-act-runner
   # Erwartet: "Runner registered successfully" + "Listening for tasks"
   ```
10. In Gitea: **Site Administration → Actions → Runners** → `qnap-runner-1` mit Status `Idle`

## Warum absolute Pfade

Relative Pfade (`./gitea-data`) werden von Container Station relativ zum internen Application-Directory aufgelöst (`/share/CACHEDEV1_DATA/Container/container-station-data/application/<stack>/…`). Beim Ersetzen oder Neuanlegen eines Stacks kann Container Station dieses Directory neu erzeugen oder löschen — das führt zum Datenverlust wie beim letzten Versuch.

Absolute Pfade unter `/share/Container/gitea/` sind **außerhalb** der Container-Station-Verwaltung. Stack kann beliebig gelöscht, umbenannt, migriert werden — die Daten bleiben, weil Container Station sie nicht als "seine" Volumes betrachtet.

## Repo-Secrets für CI/CD

Im nexus-Repo → **Settings → Actions → Secrets** eintragen:

| Secret                  | Zweck                                  |
| ----------------------- | -------------------------------------- |
| `STAGING_SSH_KEY`       | Private SSH-Key für Deploy             |
| `STAGING_SSH_HOST`      | Staging-Hostname                       |
| `STAGING_SSH_PORT`      | SSH-Port (meist `22`)                  |
| `STAGING_SSH_USER`      | Deploy-User                            |
| `STAGING_DEPLOY_PATH`   | Deploy-Verzeichnis auf Staging-Host    |
| `STAGING_APP_HOST_PORT` | App-Port auf dem Host                  |
| `STAGING_GHCR_USERNAME` | Registry-User                          |
| `STAGING_GHCR_TOKEN`    | Registry-Token mit Package-Write-Scope |
| `PROD_*`                | Analog für Produktion                  |

## Backup-Empfehlung (nach diesem Vorfall umso wichtiger)

Tägliches Backup per Cron oder QNAP-Snapshot auf `/share/Container/gitea/`:

```bash
# Beispiel — in QNAP Cron oder Systemd-Timer
sudo tar -czf /share/Backups/gitea-$(date +%Y%m%d).tar.gz /share/Container/gitea/
# Retention: letzte 14 Tage behalten
find /share/Backups/ -name 'gitea-*.tar.gz' -mtime +14 -delete
```

Zusätzlich: QNAP **Storage & Snapshots** → Volume-Snapshots für `/share/Container/` aktivieren.

## Sicherheits-Notiz

`/var/run/docker.sock` ist gemountet, damit `release-image.yml` Images bauen kann. Das gibt jedem Workflow-Job vollen Zugriff auf den QNAP-Docker-Daemon — akzeptabel für Single-Tenant mit eigenen Repos. Für untrusted Repos stattdessen docker-in-docker Sidecar (auf Anfrage).

## Troubleshooting

**Runner registriert sich nicht:**

- Token abgelaufen → neuen in Gitea-UI erzeugen → Env-Var aktualisieren → `act_runner`-Container neu starten
- `GITEA_INSTANCE_URL` muss im internen Docker-Netz erreichbar sein (`http://gitea:3000`), nicht über Nginx-Proxy
- Fehler `open /data/.runner: no such file or directory` → der custom `entrypoint` überschreibt das Standard-Auto-Register-Skript des Images. Lösung: expliziter `act_runner register`-Aufruf vor `daemon` (siehe oben im Entrypoint-Block)
- Fehler `instance address is empty` trotz gesetzter Env-Vars → Docker Compose interpoliert `$VAR` im YAML **bevor** der Container startet. Im Entrypoint-Skript müssen Variablen als `$$VAR` geschrieben werden, damit ein literales `$` an den Container geht und von der Shell zur Laufzeit aufgelöst wird

**Postgres startet nicht, "permission denied":**

- `postgres-data` gehört nicht UID 70 → `sudo chown -R 70:70 /share/Container/gitea/postgres-data`

**Gitea startet nicht, "cannot create /data/...":**

- `gitea-data` gehört nicht UID 1000 → `sudo chown -R 1000:1000 /share/Container/gitea/gitea-data`

**Jobs scheitern bei Docker-Operationen:**

- Socket-Mount prüfen
- `container.network` in der inline-generierten Runner-Config muss zum echten Docker-Netzwerknamen passen (`docker network ls`)
- Fehler `docker: command not found` → Job-Container hat kein Docker-CLI. Runner-Label muss ein Image verwenden, das `docker` mitbringt (z.B. `catthehacker/ubuntu:act-latest`). `node:*`-Images reichen nicht, weil dort nur Node installiert ist
- Fehler `Get "https://github.com/..." ... dial tcp: lookup github.com on 127.0.0.11:53: server misbehaving` → Docker-interner DNS im `gitea_gitea`-Netz forwarded unzuverlässig. Fix: `container.options: "--dns 8.8.8.8 --dns 1.1.1.1"` in der Runner-Config setzen, damit Job-Container externen DNS direkt nutzen

**DNS-Timeouts / `server misbehaving` beim `actions/checkout`-Clone — komplette Lösung:**

Symptom: Jobs scheitern mit

```text
Get "https://github.com/actions/checkout/info/refs?service=git-upload-pack":
dial tcp: lookup github.com on 127.0.0.11:53: server misbehaving
```

oder hängen minutenlang bei `cloning https://github.com/actions/checkout`.

### Die Fallstricke (wichtig zum Verstehen, warum es ZWEI Fixes braucht)

`act_runner` führt beim Start eines Jobs **zwei unabhängige** Clone-Operationen aus:

1. **Im act_runner-Prozess selbst** (vor Job-Container-Start): clont Actions nach `/data/workflows/...`, benutzt seine eigene `/etc/resolv.conf`.
2. **Im Job-Sub-Container** (während Job-Run): benutzt seine eigene `/etc/resolv.conf`.

**Beides** zeigt per Default auf `127.0.0.11` (Dockers embedded DNS im `gitea_gitea`-Netz), das wiederum an den QNAP-Host-Upstream forwarded. Dieser Upstream ist auf QNAP oft unzuverlässig → `server misbehaving`.

Der `container.options: "--dns ..."`-Eintrag in der Runner-`config.yaml` betrifft **nur Fall 2** (Job-Sub-Container). Fall 1 (act_runner selbst) braucht einen separaten Fix am Compose-Service.

### Copy-Paste-Lösung (beide Ebenen gleichzeitig)

**1) Am `act_runner`-Service in der compose — setzt seine eigene `/etc/resolv.conf` auf Upstream-DNS** (in der obigen compose.yml schon eingebaut):

```yaml
act_runner:
  image: gitea/act_runner:latest
  # ... restliche Config ...
  dns:
    - 8.8.8.8
    - 1.1.1.1
  dns_search: []
```

**2) In der inline-generierten `/config.yaml` — setzt Upstream-DNS in jedem Job-Sub-Container** (ebenfalls schon eingebaut):

```yaml
container:
  network: gitea_gitea
  options: "--dns 8.8.8.8 --dns 1.1.1.1 --dns-search ."
  # `--dns-search .` entfernt jede geerbte Search-Domain → keine verirrten NXDOMAIN-Retries
```

Nach dem Ändern: Stack neu deployen, damit der act_runner-Container mit der neuen DNS-Config startet.

### Verifikation nach dem Deploy

```bash
# 1. DNS aus Sicht des act_runner-Containers selbst — muss sofort eine IP liefern
docker exec gitea-act-runner sh -c 'cat /etc/resolv.conf && nslookup github.com'
# Erwartet: nameserver 8.8.8.8 / 1.1.1.1, nicht 127.0.0.11
#           Name: github.com, Address: 140.82.x.x

# 2. DNS aus Sicht eines Job-Sub-Containers
docker run --rm --network gitea_gitea --dns 8.8.8.8 alpine:3 \
  sh -c 'apk add --no-cache bind-tools >/dev/null && dig +short github.com'
# Erwartet: sofortige IP-Antwort
```

Hängen oder `server misbehaving` → siehe Alternativen unten.

### Alternative A — Docker-Daemon global fixen (robuster, wirkt auf ALLE Container)

In `/etc/docker/daemon.json` auf dem QNAP:

```json
{
  "dns": ["8.8.8.8", "1.1.1.1", "9.9.9.9"],
  "dns-opts": ["ndots:1", "timeout:2", "attempts:3"]
}
```

Dann Docker-Daemon restart (Container Station → Advanced → Restart Docker). Macht die compose-seitigen `dns:`-Einträge überflüssig, hilft aber auch jedem anderen Container.

### Alternative B — Pre-warm der Action-Repos (umgeht den Clone komplett)

`act_runner` cached bereits geklonte Action-Repos unter `/data/cache/actions`. Einmal manuell anstoßen:

```bash
docker exec gitea-act-runner sh -c '
  mkdir -p /data/cache/actions/github.com/actions &&
  cd /data/cache/actions/github.com/actions &&
  for repo in checkout setup-node cache upload-artifact download-artifact; do
    [ -d "$repo" ] || git clone --depth 1 "https://github.com/actions/$repo"
  done
'
```

Danach laufen Jobs ohne DNS-Dependency zu github.com durch, solange der Cache nicht gelöscht wird.

### Alternative C — Host-Network für Job-Container

```yaml
container:
  network: host
  # options ohne --dns
```

Nachteil: Jobs sehen Host-Ports (Security-Impact bei Multi-Tenant). Nur als Notnagel.

### Parallele-Job-Drosselung

Parallele Job-Starts erzeugen kurzzeitig 5–10 gleichzeitige DNS-Lookups; wenn dein Upstream-DNS drosselt, hängen Connects ohne sauberes Fail. Dann in der Runner-`config.yaml`:

```yaml
runner:
  capacity: 2 # statt 4 — reduziert parallele Starts
```

**Debug-Snippet — wer resolved gerade was:**

```bash
# Alle Container mit ihrer resolv.conf-Config
for c in $(docker ps --format '{{.Names}}'); do
  echo "=== $c ==="; docker exec "$c" cat /etc/resolv.conf 2>/dev/null
done
```

**`uses: actions/checkout@v4` schlägt fehl:**

- `GITEA__actions__DEFAULT_ACTIONS_URL=https://github.com` gesetzt?
- Gitea-Container braucht Outbound-Internetzugang zu github.com
