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.