Building the Kubernetes cluster gave me somewhere to run services. The next problem was remembering how those services were configured. Helm values, chart versions, and changes made through a terminal are easy to lose track of when they live in different places.
I use Argo CD to connect the cluster to gitops-repo. The repository describes the platform I want to run; Argo CD compares that description with the resources in Kubernetes and applies changes. It is a deliberately small setup, covering Cilium, Sealed Secrets, and the VictoriaMetrics collection stack.
A root application points to the rest
The running root-app watches the argo-apps/ directory on the repository's main branch. That directory contains an Argo CD Application for each platform component. The root application manages those Application objects, and each child application manages its own resources. This is the app-of-apps pattern in a form small enough to follow from the directory tree.
argo-apps/ platform/
cilium-argocd-app.yaml cilium/values.yaml
sealed-secrets-argocd-app.yaml sealed-secrets/values.yaml
victoria-metrics-argocd-app.yaml victoria-metrics/values.yaml
victoria-metrics/manifests/
The child applications combine two sources: an upstream Helm chart pinned to a version, and the values in my Git repository. Argo CD's $values reference joins the two. That means I can change a lab setting without copying the entire upstream chart into my repository, while still recording which chart version it belongs to.
For example, this is the source configuration from my Cilium Application, shortened to the fields that connect the chart to the values:
sources:
- repoURL: https://helm.cilium.io/
chart: cilium
targetRevision: 1.18.3
helm:
valueFiles:
- $values/platform/cilium/values.yaml
- repoURL: https://github.com/ducnguynx/gitops-repo.git
targetRevision: main
ref: values
ref: values gives the Git source a name; $values/ refers to its repository root. The chart comes from the first source and the configuration comes from the second. A chart upgrade changes targetRevision in the Application, while a setting such as enabling Hubble's UI changes the values file. That separation makes the review smaller: I can see whether I am changing upstream software, my own configuration, or both. Argo CD documents this arrangement under multiple sources for an Application.
The root application is part of the bootstrap boundary. It points at the child manifests, but its own definition is not present in the local checkout I inspected. The repository therefore documents the managed platform, rather than every step needed to install Argo CD from an empty cluster.
What a commit changes
The applications enable automatic synchronization, pruning, and self-healing. A committed values change becomes desired state; pruning lets Argo CD remove managed resources deleted from that state, and self-healing lets it reconcile edits made directly in the cluster. These are explicit settings in the manifests, following Argo CD's automated sync policy.
For Cilium, the values specify VXLAN tunneling and kube-proxy replacement, and enable Hubble relay and UI. The Kubernetes API host is the kube-vip address from the infrastructure project. That is one small but important connection between the bootstrap layer and the GitOps layer: the network component needs to know how to reach the API it depends on.
With that structure, a Hubble UI configuration change has a concrete route through the system. I edit platform/cilium/values.yaml, commit it to the tracked branch, and Argo CD renders the pinned chart with that revision of the values. The child application's diff shows the resulting Kubernetes changes. The root Application only needs a new sync of its own resources when an Application definition changes; the child can pick up a values change through its own Git source.
Self-healing affects how I work on the cluster, too. An edit made directly to a managed resource can be reconciled back to the Git version, so a lasting configuration change belongs in the repository. For a bad configuration commit, the recovery path is to correct or revert the tracked change and let Argo CD reconcile it. That restores desired configuration; it does not automatically reverse changes to application data.
The metrics application has a more subtle adjustment. The VictoriaMetrics operator generates its webhook certificate, so the application ignores differences in that Secret's data and the webhook's CA bundle. With RespectIgnoreDifferences=true, that exception also applies during synchronization. It gives the operator ownership of those generated fields without hiding differences across the whole application.
The exception is scoped to vmo-validation and vmo-admission, rather than every Secret or webhook in the namespace. The manifest's comment records the reason: generated certificate data can otherwise keep the application reporting OutOfSync. This is one of the places where GitOps needs a decision about ownership, because two controllers trying to enforce different values for the same generated field would keep undoing each other's work.
Collect at home, store on the VPS
The VictoriaMetrics chart runs vmagent, node-exporter, and kube-state-metrics in the cluster. Node-exporter exposes Linux host measurements, while kube-state-metrics describes Kubernetes objects. vmagent collects the configured targets and sends samples to the remote-write endpoint on my cloud VPS, adding the label cluster: homelabs-kube.
The local values disable VictoriaMetrics storage, Grafana, vmalert and Alertmanager. Scheduler, controller-manager and etcd scraping are also disabled in this configuration. So the project is a working collection path with specific coverage, rather than a claim that every control plane component is monitored or that alerting is already complete.
The remote-write connection uses a client certificate. A SealedSecret named vmagent-mtls carries encrypted credential data in Git, and the in-cluster Sealed Secrets controller produces the Secret vmagent uses. On the other end, Caddy verifies that client certificate before forwarding writes to VictoriaMetrics. The VPS article follows what happens after those samples leave the cluster.
The handoff is expressed through Secret references in the vmagent values. This excerpt contains the field names, not the certificate or private key:
tlsConfig:
cert:
secret:
name: vmagent-mtls
key: tls.crt
keySecret:
name: vmagent-mtls
key: tls.key
The SealedSecret and generated Secret live in monitoring, alongside vmagent. My VictoriaMetrics Application also sets path: platform/victoria-metrics/manifests on its Git source, which is how that supporting manifest is included as a resource to apply. Referencing a values file alone would supply chart settings; including the manifests directory also brings the encrypted credential into the deployment. Those two uses of the same Git source are easy to conflate when reading an app-of-apps setup for the first time.
Reading health and sync separately
Everything including the Cilium, Sealed Secrets, and VictoriaMetrics applications reported Synced and Healthy. The root application reported OutOfSync and Healthy. The monitoring SealedSecret reported a successful sync, and the VPS metrics API returned stored up series.
That snapshot is a useful reminder of what the dashboard is telling me. Resource health and agreement with Git answer different questions, and a healthy child workload does not by itself prove that the parent application's desired state matches. I still need to inspect the diff when the root reports drift.
The benefit of this setup is being able to trace a platform setting from a commit, through an Application and Helm values, to a running resource. It gives me a practical place to learn how reconciliation behaves, including the cases where another controller legitimately owns part of an object.