❯ gitops · Running

Migrer un homelab GitOps de GitHub vers Gitea self-hosted Migrating a GitOps homelab from GitHub to self-hosted Gitea

Basculer la source de vérité d'un app-of-apps ArgoCD de GitHub vers un Gitea interne — conversion des repos, repointage en cascade, un pull-mirror cassé qui masquait un état obsolète, la cred en SealedSecret, et garder une ancre de rollback propre. Flipping the source of truth of an ArgoCD app-of-apps from GitHub to an internal Gitea — repo conversion, a cascade repoint, a broken pull-mirror that hid stale state, the SealedSecret credential, and keeping a clean rollback anchor.

· 6 min de lecture 6 min read ·
k3s-clustergiteagitopsargocdmigrationk8s
🇫🇷 Lire cet article en français

The starting point

For months, the homelab ran on a classic GitOps loop: the code lives on GitHub, ArgoCD watches [email protected]:YellowBeanie/k3s-homelab.git (branch actual-state), and a self-hosted Gitea keeps a local mirror of it — 23 repositories synchronised by pull every 8 hours. GitHub was the source of truth; Gitea, a convenient backup copy.

The goal of this migration: invert the relationship. Make Gitea the source of truth — code, GitOps and CI — and relegate GitHub to a plain backup. Less external dependency, and no more reason to pay a subscription for something already self-hosted.

On paper, it is a repoURL change. In practice, an app-of-apps driving 25 applications does not enjoy having the ground moved under its feet without preparation.

Step 1 — Gitea becomes primary

A Gitea pull-mirror is read-only: all it does is copy GitHub. For Gitea to become writable — and therefore the source of truth — the mirror has to be converted into a normal repository. On the Gitea side it is a state flip: remove the repo from the mirror table and set is_mirror = false.

UPDATE repository SET is_mirror=false WHERE id=$RID;
DELETE FROM mirror WHERE repo_id=$RID;

Repeat on every repository to be flipped, add a Gitea → GitHub push-mirror (to keep GitHub current as a backup), and check each mirror’s last_error. One rule above all others: sync from GitHub right before converting, so you start from the freshest content.

The trap: the mirror was broken

This is where the migration nearly went off the rails. ArgoCD’s connectivity test towards Gitea failed with app path does not exist on a folder that does exist. Digging in: the actual-state branch of the Gitea mirror of k3s-homelab was frozen weeks in the past. The mirror-sync was cheerfully answering 200, but fetching nothing — expired mirror credentials, silent failure. Recent apps (alerting, victorialogs…) were simply missing.

The danger was real: converting that mirror as-is would have rolled the cluster back to a stale state. The counter-move: do not trust the mirror. Clone the current content from GitHub, repoint the repoURLs, and force-push to Gitea. Gitea then restarts from the real current state, not from the outdated copy.

Lesson: a mirror-sync that answers 200 is no guarantee the content is up to date. Always check the branch’s actual HEAD, not the API return code.

Step 2 — ArgoCD reads Gitea (internally)

ArgoCD runs inside the cluster. The public Gitea instance, however, sits behind Cloudflare Access: any non-browser request to https://gitea.olig-homelab-2025.win is redirected to a Cloudflare login page. ArgoCD is not a browser — it would be turned away at the door.

The solution is obvious once stated: ArgoCD talks to Gitea through the internal Kubernetes service URL, which never leaves the cluster and therefore never meets Cloudflare Access:

http://gitea.gitea.svc.cluster.local:3000/<gitea-user>/k3s-homelab.git

The repository is declared to ArgoCD through a repository-type secret (internal URL + a read-only Gitea token), then the connection is validated with a throwaway app in manual sync: if it manages to read the repo, the path is good — without touching a single managed app.

The credential as a SealedSecret (and its trap)

A secret created by hand with kubectl disappears at the next cluster rebuild. For durability, seal it with kubeseal and commit it to the repo (argocd/bootstrap/).

Trap hit along the way: if a Secret of the same name already exists and is not managed by the SealedSecret, the controller refuses to overwrite it — Synced=False with Resource already exists and is not managed by SealedSecret. The manual secret had to be deleted so the controller could recreate it cleanly from the SealedSecret. Next time: seal from the start, do not create the secret by hand first.

The flip: repoint the root, let it cascade

The heart of the migration fits in a single command. The app-of-apps works in layers: a root app reads argocd/apps/*.yaml, and each file defines a child app with its own repoURL. So:

  1. Edited the 25 repoURLs (24 child apps + the root) to the internal Gitea URL, committed to Gitea.
  2. Repointed the live root app to Gitea (kubectl patch).

The root, running automated + selfHeal, read the updated definitions from Gitea and propagated the new repoURL to every child app in the same breath:

[1] gitea=25  github=0  healthy=25/25  >>> CONVERGE

Convergence in about a dozen seconds. And above all: zero pods restarted. The flip only changes the source being read, not the content of the manifests — so ArgoCD had nothing to reapply.

The rollback anchor

Every irreversible change deserves a safety net. Here the anchor is free: do not push the repoURL change to GitHub. GitHub keeps the old version, with the GitHub repoURLs. If anything goes wrong, rollback is immediate — repoint the root app at GitHub, which still serves the old definitions, and the cascade runs backwards.

The Gitea → GitHub push-mirror of k3s-homelab therefore stays disabled for as long as that anchor is worth keeping. Once a few days of confidence have accumulated, it can be switched on to turn GitHub into a genuine living backup.

Outcome

  • 25 ArgoCD apps migrated to Gitea, Synced + Healthy, with no service interruption. (24 as of early July 2026 — Homepage has since been decommissioned on 2026-06-28.)
  • A source of truth that is 100% self-hosted: ArgoCD reads the internal Gitea, commits go to Gitea, GitHub is nothing more than a backup.
  • One major trap avoided — the stale mirror — thanks to a connectivity check before the flip.
  • A clean rollback path preserved from beginning to end.

CI follows the same logic — builds and digest-pin move from GitHub Actions to Gitea Actions, on a runner hosted on the NAS. That is the subject of Self-hosted CI with Gitea Actions.

k3s-clustergiteagitopsargocdmigrationk8s