← all writing

GitOps for a homelab, the pragmatic shape

Most of what's written about GitOps is for companies. Platform teams, multiple environments, complicated promotion pipelines from dev to staging to prod. The shape of those problems is real and the tools that solve them are real. They're also overkill for what I'm doing at home, which is one cluster, one me, and a Mac on my desk.

The patterns mostly transfer. You don't get to ignore them just because the scale is small. But you do get to cut them down to fit. This is the cut-down shape I run.

#What GitOps actually buys you

Before any of this matters, an honest definition. GitOps is the practice of treating a git repository as the source of truth for what's deployed, and using an agent inside the cluster to reconcile the cluster against the repo automatically. You commit a change, push it, and the cluster picks it up and applies it. You don't kubectl apply from your laptop. The cluster watches the repo.

What this buys you isn't really new automation. You could do all the same things with a shell script that wraps git pull && kubectl apply -k .. What it buys you is a clean separation between the description of what should be running (in git) and the act of making it run (the agent doing the apply). Once that separation exists, three things follow.

First, every change has a history attached to it. You don't have to remember when you bumped the linkding image version. Git remembers. You don't have to remember why. The commit message does.

Second, every change can be reviewed before it lands. Even reviewing your own pull requests against yourself is useful. It's another beat where you can ask "wait, is this actually what I want." Most of the bad commits I've caught in my own homelab were caught by reading my own diff.

Third, drift between the cluster and the repo is detectable. The agent can tell you when something in the cluster doesn't match what's in git, which means somebody (probably you) changed it imperatively without committing. That's a useful signal even when the right move is to update the git repo to match the cluster.

#The agent: ArgoCD

I run ArgoCD, which is one of two big choices in this space. The other is Flux. Both work. I picked ArgoCD because the UI is good and I wanted the visual feedback while I was learning. ArgoCD installs as a Helm chart, runs in its own namespace, and watches a list of Applications, its own custom resource. Each Application says: "this git repo at this path holds manifests that should be applied to this namespace."

The pattern most people use is one Application per workload. Linkding gets one. The monitoring stack gets one. Each one points at a directory in the repo. That works, but it means every new workload requires you to also create an Application resource for it. Which means hand-creating the Application via kubectl apply the first time. Which is the imperative step GitOps was supposed to eliminate.

#App-of-apps

The trick to closing that loop is called "app-of-apps." You make one Application whose entire job is to watch a directory of other Applications in the repo. Drop a new YAML file into that directory, push it, and the root Application picks it up, creates the new Application as a real resource in the cluster, and from then on the new Application reconciles its own workload.

In my repo, the layout looks like this:

argocd/
├── _root-application.yaml      # the bootstrap, applied once by hand
└── applications/
    ├── databases.yaml          # ← these get picked up automatically
    ├── jobs-demo.yaml
    ├── linkding.yaml
    └── rbac.yaml

The _root-application.yaml is the one Application I have to create imperatively. It says: "watch argocd/applications/ and create whatever it finds there." After that, the directory is the source of truth for which workloads exist. Adding a new workload is two commits: one with the workload's manifests in its own subdirectory, one with a new Application file in argocd/applications/. After the second commit lands, the new workload appears.

This is the only piece of GitOps theology I think is worth applying even at homelab scale. Without app-of-apps, GitOps degrades into a partial discipline where you're using git for workloads but imperatively managing the Applications. With it, the boundary is clean.

#What I deliberately leave imperative

Not everything is in GitOps. Some things are, on purpose, installed by Helm directly with no Application resource:

The pattern is: anything that ships with CRDs that other workloads consume, I install by Helm and live with the drift risk. The actual workloads (linkding, jobs, databases, anything that's just Deployments and Services and PVCs) are all under GitOps.

The honest version of "I'll migrate it eventually" is that the cost of being imperative for these is low enough I've been comfortable leaving them, and the cost of figuring out the helm-under-argocd dance is higher than I want to pay just now. This is the kind of trade-off you make at homelab scale that you wouldn't make at company scale. At company scale you'd pay the cost once and forget about it. At homelab scale you'd pay the cost on every cluster rebuild, which is also rare, but you're the one doing the rebuild.

#The visibility loop

ArgoCD's UI is the thing that makes the whole exercise feel like operation rather than configuration. Open the dashboard and you see a card per Application: green if synced and healthy, yellow if out of sync, red if degraded. Click into one and you see the tree of every resource Kubernetes thinks belongs to it: Deployment, ReplicaSet, Pods, Service, Ingress, the Certificate, the resulting Secret, all of it. You can scroll back through sync history and see every diff that landed.

The reason I bring this up is that none of it is strictly necessary for GitOps to work. The agent does its job whether you watch it or not. But the UI is what makes me actually look at the cluster occasionally. When I see something amber, I check. When I push a change, I watch the sync land. That feedback loop is a real piece of why GitOps holds at small scale; without something visual reminding you the cluster is alive, you forget to care.

#What this doesn't solve

Two things, both important.

GitOps doesn't manage external state. If my workload depends on a Bitwarden secret, the Bitwarden secret is not in git. If it depends on a DNS record at Route 53, the Route 53 record is not in git. You can stretch GitOps to cover some of those (external-secrets resources are in git, route53 records can be managed via Terraform with another GitOps tool), but at homelab scale you usually accept the seam and document where the external state lives.

GitOps also doesn't solve "I broke the cluster and ArgoCD is part of the cluster." If the API server is down, ArgoCD can't reconcile anything. If you delete the wrong Application with prune: true, you delete the workload too. You still need backups, you still need a way to restore from scratch, you still need to know what you'd do if the cluster went away. GitOps makes "describe the desired state" easy. It does not make "the desired state can survive a node failure" automatic.

Knowing what GitOps doesn't do is more useful, in the long run, than knowing what it does. The things it doesn't do are the things you still have to think about.


← all writing