The context
After moving GitOps from GitHub to Gitea (previous post), one last dependency remained: CI. Three pipelines were running on GitHub Actions — building Docker images, pushing to GHCR, then digest-pinning into the homelab repo. Migrating them to Gitea Actions closes the loop: not a single minute of compute left at GitHub.
Gitea Actions is roughly 95% compatible with the GitHub Actions syntax and can even pull marketplace actions (actions/checkout, docker/build-push-action…) from github.com. The real work is not in the YAML — it is in the runner.
The runner is not a cluster node
A first useful clarification: an act_runner runner does not need to be part of the k3s cluster. It is an outbound agent, exactly like a GitHub self-hosted runner: it connects to Gitea, registers with a token, and polls to pick up jobs.
That is precisely what makes it possible to host it on the NAS — an x86 machine (Intel N100) running Docker Compose, outside the Raspberry Pi cluster. The runner becomes one more container in the NAS stack:
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: that :latest on the runner image itself is a deliberate exception — this is CI infrastructure, not an app deployed via GitOps by ArgoCD. The “no floating tag” principle developed in Floating tags considered harmful applies to the images running on the cluster, not to the NAS’s Docker Compose tooling.
Not to be confused with a Kubernetes-native CI (Tekton, Argo Workflows), which would indeed require cluster nodes. Gitea Actions has its own protocol, decoupled from k8s.
The Cloudflare Access trap
First startup, and the runner refuses to register:
Cannot ping the Gitea instance server
error="unknown: invalid content-type: \"text/html\"; expecting \"application/proto\""
The runner talks to Gitea over gRPC/Protobuf. But Gitea’s public URL is protected by Cloudflare Access: instead of the expected application/proto response, Cloudflare returns an HTML login page. The protocol breaks outright.
The workaround: bypass Cloudflare and hit Traefik directly on the LAN. You keep the hostname (so that everything lines up), but resolve it to a cluster node via extra_hosts:
extra_hosts:
- "gitea.olig-homelab-2025.win:10.0.0.21"
environment:
GITEA_INSTANCE_URL: "http://gitea.olig-homelab-2025.win"
Immediate result:
Successfully pinged the Gitea instance server
Runner registered successfully.
The same trap, one notch further: the job containers spawned by the runner do not inherit that extra_hosts. An actions/checkout was therefore failing — the clone was redirected to cloudflareaccess.com, and git gave up the ghost (exit 128). The fix: push the --add-host all the way down into each job container, through the runner config:
# data/config.yaml
container:
options: "--add-host gitea.olig-homelab-2025.win:10.0.0.21"
arm64 builds on an amd64 NAS
The cluster is 100% Raspberry Pi (arm64), but the NAS is x86 (amd64). The images must be arm64. As on GitHub Actions, you emulate via QEMU — except that here the emulation has to be available on the runner’s host. You install the binfmt handlers once on the NAS:
docker run --privileged --rm tonistiigi/binfmt --install arm64
# → qemu-aarch64 handler registered
After that docker/setup-qemu-action + docker/setup-buildx-action + platforms: linux/arm64 work just like on GitHub. It is slower (an arm64 nginx image build takes ~12 min under emulation on an N100), but it is reliable.
The registry: keeping GHCR
Should the image registry have been migrated to Gitea’s as well? No — that would have meant reconfiguring containerd on all 6 Pi nodes to pull internally. GHCR is free, and the cluster already pulls its private images through an imagePullSecret. So we keep GHCR.
To push from Gitea Actions, there is no auto-provided secrets.GITHUB_TOKEN any more. A pleasant surprise: the token already stored in the cluster’s ghcr-pull secret actually had write permissions (not just read). Reused as-is as the Gitea secret GHCR_TOKEN, it pushes without anything new having to be created — a token to re-scope more cleanly at some point.
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USER }}
password: ${{ secrets.GHCR_TOKEN }}
As for the application secrets (the VITE_* of one of the sites), they were public by design (embedded in the JS bundle) — therefore retrievable straight from the deployed site, then re-injected as Gitea secrets at the account level.
The digest-pin, Gitea edition
The final step of the pipeline — pinning the immutable digest into the manifest — stays fundamentally the same (the pattern is detailed here). Only the target changes: the push now goes to Gitea, via a Gitea token, taking the same LAN path as the runner (so no 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 (which already reads Gitea) picks up the commit and redeploys. The loop is closed, fully self-hosted.
Outcome
- 3 pipelines (two sites plus one cross-repo build) migrated to Gitea Actions, validated end to end: arm64 build → GHCR → Gitea digest-pin → ArgoCD synced.
- A single runner on the x86 NAS, outside the cluster, serving all the homelab’s CI.
- Two traps specific to self-hosting behind Cloudflare Access — runner and job containers — solved with a targeted LAN bypass.
- Not a single CI minute consumed at GitHub any more. GHCR stays as the registry (free), the only residual link — migratable later if the urge for 100% self-hosted comes back.