GitOps Strategy¶
ArgoCD manages the cluster state declaratively: if it is not in Git, it is not in the cluster, and if you put it there anyway the next reconcile puts it back — with one qualification for the platform, below.
Structure¶
flowchart LR
subgraph "Bootstrap (once, by hand)"
PJ[argocd-projects/projects.yaml]
AA[argocd/application.yaml]
end
AA --> AR[Application argocd]
AR --> |"argo-cd chart + values.yaml"| SELF[ArgoCD itself]
AR --> |"payload/argocd/applicationset-platform.yaml"| AS[ApplicationSet platform]
AS --> |"payload/platform/*/application.yaml"| INFRA[Every component]
AS --> |"12-workloads"| WA[Application workloads]
WA --> |"payload/workloads/applicationset.yaml"| APPS[ApplicationSet apps]
APPS --> |"homelab-apps: */application.yaml"| WL[Every workload]
argocd is the only Application not generated by the ApplicationSet and the
only one applied by hand. It syncs the chart that runs ArgoCD and, through a
second source, the platform ApplicationSet, so changes to the rollout arrive
through Git. It keeps selfHeal, which the generated Applications give up
under RollingSync. Its chart values are described in
Platform → ArgoCD, along with the recovery
commands for the day ArgoCD syncs a broken ArgoCD config.
Everything else is a component directory with one application.yaml,
including ArgoCD's own supporting resources:
| Application | Stage | Holds |
|---|---|---|
argocd-projects |
00-projects |
the apps, infra and system AppProjects |
argocd-config |
08-services |
ArgoCD's HTTPRoute, OIDC ExternalSecret and Grafana dashboard — after the Gateways and OpenBao exist |
The AppProjects cannot arrive through ArgoCD alone: an Application naming a
project that does not exist is rejected. projects.yaml is applied once at
bootstrap, ahead of the argocd Application that names system, and
argocd-projects adopts it on its first sync. A broken project definition is
fixed with kubectl apply, not a sync.
Each application.yaml is a complete Application, not a fragment: the
generator copies its labels, annotations, finalizers and spec unchanged, and
Renovate and the Makefile read it like any other manifest. A file without a
homelab.wlkr.ch/stage label fails the whole ApplicationSet rather than
quietly dropping out.
Rollout order¶
The platform ApplicationSet uses a RollingSync strategy. Each Application
carries a homelab.wlkr.ch/stage label, the ApplicationSet has one step per
stage, and a step starts only once every Application in the step before it is
Synced and Healthy. The stages and what each waits for are in
Platform → Rollout order.
flowchart LR
PROJ[00 projects] --> CRDS[01 crds]
CRDS --> NET[02 network] --> CTRL[03 controllers] --> STOR[04 storage]
STOR --> SEC[05 secrets] --> CERT[06 certificates] --> ING[07 ingress]
ING --> SVC[08 services] --> BACK[09 backends] --> AG[10 agents] --> POL[11 policy]
POL --> WORK[12 workloads]
Resource-level sync waves still order resources inside one Application: the
certificates Application applies its ExternalSecret, then the
ClusterIssuers, then the Certificates.
Nothing may wait on a later stage¶
A step that needs Synced and Healthy turns every forward dependency into a deadlock: a resource that cannot apply, or cannot go healthy, until a later stage never finishes its step, so the later stage never starts. Three rules keep the graph pointing one way:
- A resource lives with what it needs, not with what it configures. The
ClusterSecretStoreneeds a running OpenBao, so it is inopenbao, notexternal-secrets. cert-manager's issuers and certificates need the Route53 credentials in OpenBao, so they are incertificates, notcert-manager. - CRDs come first. A missing kind is a failed sync, and a failed sync is never Synced.
- Routes do not gate. An
HTTPRoutestays Progressing until a Gateway accepts it, and the Gateways come late because they need certificates. The routes incilium,rook-cephandopenbaocarryargocd.argoproj.io/ignore-healthcheck: "true"so their Applications can be Healthy before07-ingress.
A new component goes in the earliest stage after everything it needs. If that stage is later than something that needs it, the dependency is in the wrong Application.
Bootstrap pauses at OpenBao¶
05-secrets is where a fresh cluster stops on purpose. OpenBao's pods report
Ready while sealed, but the ClusterSecretStore beside it stays Degraded until
OpenBao is initialised, unsealed and has the Kubernetes auth role, so
06-certificates does not start until make bao-init and make bao-unseal.
06-certificates then waits for make bao-secrets to write the Route53
credentials. Nothing times out; the rollout resumes once the store validates.
What the staging costs¶
Under RollingSync the ApplicationSet controller, not ArgoCD's automated sync,
syncs platform Applications. The syncPolicy.automated block is still read for
prune and retry still applies, but automated sync is switched off on every
generated Application and a sync is triggered only when its revision or spec
changes:
- No self-heal. A resource edited or deleted by hand stays that way until
the next change to its Application. Any commit changes the revision of an
Application with a Git source; one that only installs a chart —
alloy,external-secrets,kubelet-csr-approver,kured,loki,metrics-server,prometheus-operator-crds,rook-ceph-cluster,rook-ceph-operator,snapshot-controller,velero— or pins a tag, likegateway-api-crds, changes only on a version bump. -
A failed sync waits for a person. Once
retryis exhausted the Application and everything after it wait until its revision changes or it is synced by hand: -
Only changes are gated. An Application that breaks later, with no new revision, holds nothing back; the next change that reaches it does.
- Ceph warnings block. ArgoCD maps
HEALTH_WARNto Degraded, so while Ceph warns, no change reaches anything after04-storage. - Patching a generated Application's
specachieves nothing. The next reconcile copies the file back over it.
Where a rollout is stuck, the ApplicationSet says which Application it is waiting for:
kubectl -n argocd get applicationset platform \
-o jsonpath='{range .status.applicationStatus[*]}{.step}{"\t"}{.status}{"\t"}{.application}{"\n"}{end}'
Workloads live in a second repository¶
The applications the cluster exists to run are in
JanWelker/homelab-apps, one
directory per application; the apps ApplicationSet generates an Application
from each */application.yaml there. The split keeps a platform change with a
cluster-wide blast radius apart from a workload change with a one-app one, and
keeps the reusable half of the cluster in a repository that could build
somebody else's.
The dependency runs one way: workloads reference the platform (the
apps-gateway, the rook-ceph-block StorageClass, the openbao
ClusterSecretStore, the CloudNativePG operator), and the platform references
the workloads repository exactly once — the repoURL in
payload/workloads/applicationset.yaml.
What the split costs¶
- Two repositories to clone, and a change that spans both is two pull requests that cannot merge atomically; merge the platform half first.
- The diff preview covers only this repository. Both ApplicationSets carry
argocd-diff-preview/ignorebecause the tool renders the repository it is given. - A workload can reference a platform resource that does not exist, and nothing catches it until ArgoCD tries to sync.
Ordering, and why workloads do not use RollingSync¶
The apps ApplicationSet has no RollingSync: workloads do not depend on each
other, and the platform they all depend on is guaranteed by when the
ApplicationSet is created. It ships inside the workloads Application at stage
12-workloads, so it does not exist until 11-policy is Synced and Healthy.
That is why payload/workloads/ is a sibling of payload/platform/, and why
the platform ApplicationSet names it as a second generator path rather than
a glob:
Because the workload Applications are not under a rolling strategy, they keep
selfHeal: a hand-edited workload Deployment is reverted within minutes.
Ordering within a workload uses resource-level sync waves — the CloudNativePG
Cluster is in an earlier wave, and ArgoCD's built-in health check for
postgresql.cnpg.io/Cluster waits for it before applying the application.
Moving a resource to another Application¶
Moving a manifest between directories is not a move to ArgoCD: the old
Application sees a tracked resource vanish and, with prune: true, deletes
it; the new one recreates it, possibly seconds later. For a ClusterSecretStore
that is an outage of every secret; for a child Application with the resources
finalizer it deletes everything that Application deployed. A move takes two
merges:
-
Annotate the resource where it is, and let that sync:
annotations: argocd.argoproj.io/sync-options: Prune=false argocd.argoproj.io/compare-options: IgnoreExtraneousBoth are read off the live object.
Prune=falsestops whichever Application prunes first;IgnoreExtraneouskeeps the old Application Synced while it still sees the resource, without which it stays OutOfSync and blocks its stage. 2. Move the file, keeping the annotations. The new Application takes over the tracking annotation.
The annotations can go once the second change has synced everywhere.
Version pins¶
Each version is pinned once, in the application.yaml that owns the
component, and never anywhere else. The Makefile reads targetRevision out
of those manifests for the bootstrap installs (make install-cilium,
make install-argo), so what bootstrap installs is what ArgoCD then
reconciles. Renovate updates the same line; its automerge policy is in
Maintenance.