❯ gitops · Running

CI self-hosted avec Gitea Actions sur un homelab Pi + NAS Self-hosted CI with Gitea Actions on a Pi + NAS homelab

Remplacer GitHub Actions par un runner Gitea Actions sur un NAS x86 — builder des images arm64 via QEMU pour un cluster Raspberry Pi, réutiliser le token GHCR existant, et le piège Cloudflare Access qui casse le gRPC du runner tant qu'on ne le bypasse pas en LAN. Replacing GitHub Actions with a Gitea Actions runner on an x86 NAS — building arm64 images via QEMU for a Raspberry Pi cluster, reusing the existing GHCR token, and the Cloudflare Access gotcha that breaks the runner's gRPC until you bypass it on the LAN.

· 6 min de lecture 6 min read ·
k3s-clustergiteacigitea-actionsghcrcloudflarek8s
🇬🇧 Read this article in English

Le contexte

Après avoir basculé le GitOps de GitHub vers Gitea (article précédent), il restait la dernière dépendance : la CI. Trois pipelines tournaient sur GitHub Actions — build d’images Docker, push sur GHCR, puis digest-pin dans le repo homelab. Les migrer vers Gitea Actions ferme la boucle : plus une seule minute de compute chez GitHub.

Gitea Actions est compatible à ~95 % avec la syntaxe GitHub Actions et sait même tirer les actions du marketplace (actions/checkout, docker/build-push-action…) depuis github.com. Le vrai travail n’est pas dans le YAML — il est dans le runner.

Le runner n’est pas un nœud du cluster

Première clarification utile : un runner act_runner n’a pas besoin de faire partie du cluster k3s. C’est un agent sortant, exactement comme un self-hosted runner GitHub : il se connecte vers Gitea, s’enregistre avec un token, et interroge pour récupérer des jobs.

C’est précisément ce qui permet de l’héberger sur le NAS — une machine x86 (Intel N100) qui tourne en Docker Compose, hors du cluster Raspberry Pi. Le runner devient un conteneur de plus dans la stack du NAS :

services:
  act_runner:
    image: gitea/act_runner:latest
    environment:
      GITEA_INSTANCE_URL: "http://gitea.olig-homelab-2025.win"
      GITEA_RUNNER_REGISTRATION_TOKEN: "..."
      GITEA_RUNNER_NAME: "nas-gbnas"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./data:/data

Note : ce :latest sur l’image du runner lui-même est une exception assumée — c’est de l’infra de CI, pas une app déployée en GitOps par ArgoCD. Le principe « pas de tag flottant » développé dans Floating tags considered harmful s’applique aux images qui tournent sur le cluster, pas à l’outillage Docker Compose du NAS.

À ne pas confondre avec une CI Kubernetes-native (Tekton, Argo Workflows) qui, elle, exigerait des nœuds du cluster. Gitea Actions a son propre protocole, découplé de k8s.

Le piège Cloudflare Access

Premier démarrage, et le runner refuse de s’enregistrer :

Cannot ping the Gitea instance server
error="unknown: invalid content-type: \"text/html\"; expecting \"application/proto\""

Le runner parle à Gitea en gRPC/Protobuf. Or l’URL publique de Gitea est protégée par Cloudflare Access : au lieu de la réponse application/proto attendue, Cloudflare renvoie une page HTML de login. Le protocole casse net.

La parade : bypasser Cloudflare et taper Traefik directement sur le LAN. On garde le hostname (pour que tout colle), mais on le résout vers un nœud du cluster via extra_hosts :

    extra_hosts:
      - "gitea.olig-homelab-2025.win:10.0.0.21"
    environment:
      GITEA_INSTANCE_URL: "http://gitea.olig-homelab-2025.win"

Résultat immédiat :

Successfully pinged the Gitea instance server
Runner registered successfully.

Même piège, un cran plus loin : les conteneurs de job spawnés par le runner n’héritent pas de cet extra_hosts. Un actions/checkout échouait donc — le clone était redirigé vers cloudflareaccess.com, et git rendait l’âme (exit 128). La solution : pousser le --add-host jusque dans chaque conteneur de job, via la config du runner :

# data/config.yaml
container:
  options: "--add-host gitea.olig-homelab-2025.win:10.0.0.21"

Builds arm64 sur un NAS amd64

Le cluster est 100 % Raspberry Pi (arm64), mais le NAS est x86 (amd64). Les images doivent être arm64. Comme sur GitHub Actions, on émule via QEMU — sauf qu’ici l’émulation doit être disponible sur l’hôte du runner. On installe les handlers binfmt une fois sur le NAS :

docker run --privileged --rm tonistiigi/binfmt --install arm64
# → handler qemu-aarch64 enregistré

Ensuite docker/setup-qemu-action + docker/setup-buildx-action + platforms: linux/arm64 fonctionnent comme sur GitHub. C’est plus lent (un build d’image nginx arm64 tourne ~12 min sous émulation sur un N100), mais c’est fiable.

Le registry : garder GHCR

Fallait-il aussi migrer le registry d’images vers celui de Gitea ? Non — ça aurait imposé de reconfigurer containerd sur les 6 nœuds Pi pour pull en interne. GHCR est gratuit, le cluster pull déjà ses images privées via un imagePullSecret. On garde GHCR.

Pour pousser depuis Gitea Actions, plus de secrets.GITHUB_TOKEN auto-fourni. Bonne surprise : le token déjà stocké dans le secret ghcr-pull du cluster avait en réalité des permissions d’écriture (pas seulement lecture). Réutilisé tel quel comme secret Gitea GHCR_TOKEN, il pousse sans qu’on ait à créer quoi que ce soit — un token à re-scoper plus proprement à l’occasion.

- uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ secrets.GHCR_USER }}
    password: ${{ secrets.GHCR_TOKEN }}

Quant aux secrets d’application (les VITE_* d’un des sites), ils étaient publics par design (embarqués dans le bundle JS) — donc récupérables directement depuis le site déployé, puis réinjectés en secrets Gitea au niveau du compte.

Le digest-pin, version Gitea

L’étape finale du pipeline — épingler le digest immuable dans le manifest — reste identique sur le fond (le pattern est détaillé ici). Seule la cible change : on push désormais vers Gitea, via un token Gitea, en empruntant le même chemin LAN que le runner (donc sans Cloudflare Access) :

- name: Pin digest in k3s-homelab (Gitea)
  env:
    GT: ${{ secrets.HOMELAB_GITEA_TOKEN }}
  run: |
    DIGEST="${{ steps.build.outputs.digest }}"
    git clone --depth 1 -b actual-state \
      "http://<gitea-user>:${GT}@gitea.olig-homelab-2025.win/<gitea-user>/k3s-homelab.git" hl
    cd hl
    sed -i -E "s|image: ghcr.io/.../app@sha256:[a-f0-9]+|image: ghcr.io/.../app@${DIGEST}|" app/deployment.yaml
    git commit -am "chore(app): pin image digest [skip ci]" && git push origin actual-state

ArgoCD (qui lit déjà Gitea) détecte le commit et redéploie. La boucle est fermée, entièrement self-hosted.

Bilan

  • 3 pipelines (deux sites + un build cross-repo) migrés sur Gitea Actions, validés de bout en bout : build arm64 → GHCR → digest-pin Gitea → ArgoCD synced.
  • Un runner unique sur le NAS x86, hors-cluster, qui sert toute la CI du homelab.
  • Deux pièges spécifiques au self-hosting derrière Cloudflare Access — runner et conteneurs de job — réglés par un bypass LAN ciblé.
  • Plus aucune minute de CI consommée chez GitHub. GHCR reste comme registry (gratuit), seul lien résiduel — migrable plus tard si l’envie de 100 % self-hosted revient.
k3s-clustergiteacigitea-actionsghcrcloudflarek8s