❯ gitops · Running

Rendre les manifests à leur application : Applications ArgoCD multi-source et digest épinglé dans le dépôt de l'app Giving manifests back to their app: multi-source ArgoCD Applications and a digest pinned in the app's own repo

Pendant des mois, chaque build de CI s'est terminé par un commit de robot dans le dépôt d'infrastructure : 141 de ses 487 commits. Le 2 octobre 2026, cinq apps ont rapatrié leurs manifests dans un dossier deploy/ de leur propre dépôt, lu par ArgoCD à côté du dépôt d'infra. Comment la bascule s'est faite sans redémarrer un pod, et les pièges rencontrés en route. For months, every CI build ended with a robot commit in the infrastructure repo: 141 of its 487 commits. On 2 October 2026, five apps moved their manifests into a deploy/ folder in their own repo, read by ArgoCD next to the infra repo. How the switch went without restarting a pod, and the traps found on the way.

· 9 min de lecture 9 min read ·
gitopsargocdcisealed-secretsk3s-clusterk8s
🇫🇷 Lire cet article en français

The finding: an infra repo 29% written by robots

On 2 October 2026, I count the commit authors of the cluster’s infrastructure repo, the one ArgoCD watches. Out of 487 commits, 141 do not come from a human: 97 signed by the Gitea Actions bot, 44 by the GitHub Actions one, from the days when CI still ran there. They all say the same thing, give or take the wording: “pin image digest” or “bump image”.

That is the price of the “cross-repo GitOps” model described in an earlier article. An app’s code lives in its own repo, its manifests live in the infra repo. After each build, the app’s CI clones the infra repo, rewrites the image: line with sed, commits and pushes. It works. But three things bothered me more and more:

  • Noise. A git log of the infra repo is a build record interleaved with actual decisions. Finding “when did I change the backup policy?” takes an author exclusion filter.
  • The token. Every app’s CI holds a token able to write to the repo that describes the whole cluster. Least privilege would suggest the opposite.
  • The race. Two builds finishing at the same time push to the same branch, and one of them gets its push rejected. The CI step had no retry: a rare case, so never handled.

The most extreme case was a small single-page HTML generator: 454 KB of HTML copied by hand into a ConfigMap in the infra repo, with no CI, at almost half of etcd’s 1 MiB per-object limit.

The idea: the infra repo keeps the Application and the secrets, the app keeps the rest

The new split is simple:

  • the app repo holds its code, its Dockerfile and a deploy/ folder (namespace, Deployment, Service, Ingress). Its CI pins the digest of the image it just built right there;
  • the infra repo keeps the ArgoCD Application definition and whatever must stay central: the SealedSecret used to pull the image from the private registry.

Why keep that secret on the infra side? Because a single registry token serves eight apps. When it rotates, eight SealedSecrets must be resealed, and I would rather do that in one commit in one repo than in eight commits across eight repos.

For ArgoCD to read both places, you need a multi-source Application (spec.sources, available since ArgoCD 2.6):

spec:
  sources:
    - repoURL: http://<internal-gitea>/<org>/<app>.git
      targetRevision: main
      path: deploy
    - repoURL: http://<internal-gitea>/<org>/k3s-homelab.git
      targetRevision: actual-state
      path: <app>          # now only holds sealedsecret-ghcr-pull.yaml
  destination:
    server: https://kubernetes.default.svc
    namespace: <app>

ArgoCD renders both paths and applies the union. To ArgoCD, it is a single desired state.

The prerequisite: a prefix credential instead of one credential per repo

Until then, ArgoCD only read one private repo, the infra one, with a dedicated credential. Five more repos meant five more Secrets, and a sixth the day a new app shows up.

ArgoCD can do better: a Secret labelled argocd.argoproj.io/secret-type: repo-creds applies to every repo whose URL starts with its prefix. One credential, with a read-only token, for http://<internal-gitea>/<org>/, and every new repo in the organisation is readable without adding anything.

A detail of ordering: this credential lives in a bootstrap folder that is not itself managed by ArgoCD. That is deliberate. ArgoCD cannot fetch the credential that lets it read a repo from that very repo. So it is applied by hand, once, and documented as such.

Along the way, I found in ArgoCD’s namespace a private SSH key for the old GitHub repo, from before the migration to Gitea, which no Application referenced any more. It went away the same day.

The method: copy verbatim, dry-check, switch, and only then the CI

The first app, the pilot, set an order that I then repeated four times:

  1. Copy the manifests into the app repo’s deploy/, byte for byte. No “improvements” during the move: an empty diff is the only proof nothing broke.
  2. Exclude deploy/ from the image (.dockerignore). For a static site served by nginx, forgetting that line means publishing your manifests at /deploy/deployment.yaml.
  3. Dry-check with a temporary Application without a finalizer, pointing at the new folder. Without a finalizer, deleting it deletes nothing in the cluster. You only look at what ArgoCD renders: 5 resources, no ComparisonError.
  4. Switch the real Application to sources:. Since the files are identical, ArgoCD sees no difference: the Deployment’s generation stayed at 53, no pod recreated.
  5. Only then change the CI so that it pins the digest in deploy/ instead of the infra repo.

The first build after the switch went full circle: image built, digest pinned in deploy/deployment.yaml by the CI, ArgoCD synced on that revision, generation 54, two new pods, site returning 200, and /deploy/ returning 404. The infra repo received no commit.

The pilot switched at 13:31, the pattern’s documentation followed at 13:52, and the four other apps followed during the afternoon. The last one switched at 16:05.

The blog itself: 125 files and 71 commits on the move

The blog you are reading was one of the five, and the heaviest case. Its whole Astro code (125 files: components, articles, Dockerfile, nginx configuration) still lived in a subfolder of the infra repo, with a filter so that ArgoCD would ignore it.

To keep the history, the move went through git subtree split: the 71 commits touching that subfolder since May 2026 form the history of the new repo. The CI workflow left with the code. Before the switch, one more check: the digest already pinned in the new deploy/ was the one running in production. The switch therefore happened without a restart.

The CI step that pins the digest barely changed. It simply no longer clones anything:

- name: Pin digest in deploy/deployment.yaml
  run: |
    set -eu
    sed -i -E "s|image: ghcr.io/<account>/blog@sha256:[a-f0-9]+|image: ghcr.io/<account>/blog@${DIGEST}|" deploy/deployment.yaml
    git diff --quiet deploy/deployment.yaml && { echo up-to-date; exit 0; }
    git commit -am "chore(deploy): pin image digest [skip ci]"
    git push "<app-repo-url>" HEAD:main

The trap: the pinning commit must not trigger another build

As soon as the CI commits to its own repo, a loop becomes possible: build, digest commit, new push, new build, new digest, and so on. Two safeguards, deliberately redundant:

  • the [skip ci] marker in the pinning commit message;
  • the workflow’s paths: filter, which only lists src/**, public/**, the Dockerfile and so on. deploy/** is not in it, so a commit that only touches deploy/ triggers nothing, marker or not.

And the opposite trap, hit the same day: Gitea looks for the marker in the whole message, body included. I wrote a documentation commit whose body explained that the CI uses [skip ci]. Result: no build. The run had to be triggered by hand. Since then, the rule is written at the top of the workflow: never quote that marker in a commit message, not even to talk about it.

Two weeks later

Between the last switch and 15 October, the infra repo received 151 commits. None comes from a CI bot. On the blog side, 8 of the 16 commits over the same period are digest pins, exactly where they belong: in the history of the app they concern.

The HTML generator became a proper nginx image built by its repo’s CI. The 454 KB ConfigMap is gone, and with it the manual copy-paste.

What the pattern costs, to be fair:

  • Two places to look. An app’s state depends on two branches in two repos. The comment at the top of each Application says which holds what, and it has become indispensable.
  • The bootstrap credential remains a manual step, to redo if its token rotates.
  • Eight registry SealedSecrets to reseal on every rotation. Centralised, but still eight.
  • One app stayed on the old model: its suite of 43 tests reads the manifests from the infra repo. Its next build will therefore still write to the infra repo. Moving it is a separate project.

What I take away

  1. The repo that produces the artefact writes its reference. A CI pushing into someone else’s repo is a coupling you end up paying for in noise, in overly broad permissions or in push conflicts.
  2. The infra repo keeps what must stay central: the Applications, the shared secrets, the bootstrap. Not the manifests of an app that has a life of its own.
  3. A prefix repo-creds credential avoids adding one Secret per repo. You just have to accept that it lives outside ArgoCD.
  4. Migrate without changing anything. Byte-for-byte copy, temporary Application without a finalizer, switch, and the CI last. If the generation does not move, the switch worked.
  5. Two safeguards against the build loop, marker and path filter. And never quote the marker in a commit message.
gitopsargocdcisealed-secretsk3s-clusterk8s