Thirty Apps, One Repo: Anatomy of a GitOps Homelab Cluster
Thirty Apps, One Repo: Anatomy of a GitOps Homelab Cluster
Somewhere between running my first app on Kubernetes and running my thirtieth,
the problem changed shape. It stopped being “how do I deploy this” and became
“how do I know what my cluster is, and how do I change it without
nuking & paving.” My answer is a single GitLab repo gitlab.com/caseyrobb/iac: one ApplicationSet
generator, one allowlist per cluster, one thin wrapper chart per app, and a
handful of infrastructure operators doing the rest. The production cluster,
chaos (k0s, six nodes on mini PCs at home — no, the name is not
prophetic; mostly), runs thirty-two apps out of that repo and has never had a
kubectl apply from a human.
This post is a tour of the five design choices that make that work and a few scars, because every ignoreDifferences rule in my repo is the fossil of a fight ArgoCD and I had.
1. The monorepo, and an ApplicationSet that treats absence as policy
Everything (ArgoCD’s own bootstrap, all chart wrappers, all per-cluster values) lives in one repo. The tempting alternative is a chart repo per app or a “platform” repo plus an “apps” repo, and I tried fragments of both. The monorepo won for one reason: a change and its consequences get reviewed in one diff. Adding an app means a new directory and its allowlist entry in the same merge request; CI fails if either half is missing.
The entire fleet is generated by one ApplicationSet, whose generator is a matrix of clusters × that cluster’s app list:
generators:
- matrix:
generators:
- list:
elements:
- clusterName: PATCH_ME # bootstrap patches in the real name
- git:
repoURL: https://gitlab.com/caseyrobb/iac.git
revision: HEAD
files:
- path: "clusters/{{ .clusterName }}/apps.yaml"
template:
spec:
source:
path: "apps/{{ .app }}"
helm:
valueFiles:
- values.yaml
- "../../clusters/{{ .clusterName }}/{{ .app }}.yaml"
The file that generator reads is the most important opinion in the repo:
# clusters/chaos/apps.yaml — app deploys ONLY if listed here.
- app: argocd
- app: cilium
namespaceOverride: kube-system
- app: immich
- app: keycloak
namespaceOverride: keycloak
The allowlist is the contract. Presence deploys; absence is a deliberate
decision, not an oversight, and CI enforces the inverse direction too.
Every chart under apps/ must be claimed by at least one cluster, so dead
apps can’t accrete. The sandbox cluster is the same generator pointed at a
different file, which means staging an app in prod is one line in a merge
request, reviewed like everything else.
Values layering follows the same philosophy: each wrapper chart carries its
values.yaml (defaults for all clusters) and an optional per-cluster overlay.
ignoreMissingValueFiles: true means overlays exist only where they earn their
place.
One honesty corner, because every GitOps blog pretends ignoreDifferences is
for cowards: I have exactly three, and each one is a documented truce. Two
Secrets whose data is written by the External Secrets controller (which
ArgoCD can never observe as Synced, so the alternative is a permanent red
badge and selfHeal ping-pong), and ServiceMonitors, where the
prometheus-operator defaulting webhook rewrites fields the chart never sent,
a perpetual OutOfSync that SSA applies and the webhook un-applies, forever.
Each rule states its reason in a comment. IgnoreDifferences without a written
reason is just drift with extra steps.
And because syncPolicy is automated + selfHeal, there’s one operational rule
the whole cluster lives by: nothing is hot-patched. The fix for anything is
a revert-MR to master. The emergency lever is git revert, and it’s the same
lever you use on a normal Tuesday.
2. CNI is the platform: Cilium, Gateway API, and one door
Cilium does far more than pod networking on this cluster: kube-proxy
replacement, native routing (which makes the BGP setup a post of its own,
see the OpenBGPD writeup), Hubble for observability, Envoy as the L7
data-path, and the Gateway API implementation. It installs via the appset like
any app, into kube-system via namespaceOverride, from a wrapper chart that
just pins the upstream release.
Every public service on the cluster passes through one Gateway and its
own one-screen HTTPRoute:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: neozeon-gateway
namespace: kube-system
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
gatewayClassName: cilium
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "*.neozeon.ca"
allowedRoutes:
namespaces: { from: All }
tls:
mode: Terminate
certificateRefs:
- name: wildcard-neozeon-tls-secret
Adding an app’s public face is then pure declaration, an HTTPRoute in the
wrapper chart mapping hostname + backend service to the shared gateway.
No Ingress annotations zoo, no per-app TLS config, no load-balancer
provisioning: the gateway’s LoadBalancer IP already exists (it’s one of the
addresses Cilium advertises over BGP), and cert-manager already owns the
wildcard cert. The route is the only per-app decision left, which is exactly
how it should be.
3. Secrets: Vault as the source of truth that never touches git
The secret-management posture fits in one sentence, and that’s the point: no secret material is ever committed to git or posted to chat. What lives in git is references.
The chain: a HashiCorp Vault (one node, it’s a homelab :p)
runs in-cluster on persistent storage; every credential lives under a chaos/
KV v2 mount, chaos/rustfs/rustfs-secret, chaos/keycloak/operator-client,
and so on. External Secrets Operator syncs Vault into Secrets through a single
ClusterSecretStore:
provider:
vault:
server: https://vault.neozeon.ca
path: chaos
version: v2
auth:
kubernetes:
mountPath: kubernetes
role: external-secrets
serviceAccountRef:
name: external-secrets
That auth.kubernetes block is the quiet hero. ESO authenticates to Vault by
exchanging the controller’s own projected service-account token. No static
token in git, no static token in a Secret, no rotation ceremony. When I want
a new credential in the cluster, the flow is: put it in Vault, chart’s
ExternalSecret template names the path, ArgoCD syncs. The cluster learns
secrets the same way it learns everything else: from a merge request.
The hygiene rules that grew around this are boring and absolute. Secrets that
ESO owns get /data ignored in ArgoCD (above), so selfHeal and ESO never
fight. Backups (CNPG’s ScheduledBackup to an in-cluster RustFS S3 bucket)
read per-namespace ExternalSecrets for their S3 credentials, so the backup
path and the application path share one Vault source. One rotation, both
fixed. Finally, the credential for the notification system, pushover, follows the same discipline living in Vault.
4. Storage: ZFS on the NAS, iSCSI into the cluster, and the longhorn war I retired
Storage is the least fashionable infrastructure decision and the one that
decides whether you keep the cluster. Mine is deliberately old-fashioned: a
Debian 13 NAS with a raidz2 ZFS pool exports volumes over iSCSI, and
democratic-csi in the cluster converts iSCSI targets into PersistentVolumes
with a StorageClass nas-iscsi (zfs-generic-iscsi under the hood)
that everything uses:
driver:
existingConfigSecret: democratic-csi-iscsi
config:
driver: zfs-generic-iscsi
storageClasses:
- name: nas-iscsi
reclaimPolicy: Delete
allowVolumeExpansion: true
The cluster previously ran Longhorn, the Kubernetes-native block storage product with the lovely UI and
the replica-rebalancing opinions. On homelab hardware, it was a second
distributed system to babysit in order to run the other distributed systems:
upgrade windows for the storage layer, replica anti-affinity drama whenever a
node rebooted for kernel patches, and a recovery procedure that required the
cluster to be healthy to help the cluster be healthy. I
decommissioned it, migrated the volumes, and the config above is what
replaced it: ZFS snapshots and sends on the NAS, iSCSI to the nodes, plain
TCP over the switch (homelab-fast enough), and one k0s quirk: the
node.kubeletHostPath has to point at /var/lib/k0s/kubelet, because k0s
doesn’t pretend to be kubeadm.
The trade is honest: no replicated volumes, so the NAS is a single point. I accepted it the same way I accepted a single gateway router, with the same upgrade path in my back pocket: a second NAS and DRBD or just very good snapshots. What I got back is that storage now fails in ways I understand in one layer instead of three. RustFS (an S3-compatible server, itself running on these very volumes) then serves as the backup target for the Postgres clusters (CloudNativePG writes Barman-format base backups and WAL straight into it nightly), which means the storage stack is: ZFS → iSCSI → PVs → apps and app-backups, with each hop boring enough to debug at 23:00.
5. Certificates and DNS: the wildcard that makes the edges line up
Two small operators finish the perimeter, and they’re best explained together because they solve one problem from both ends: an app should become reachable by writing a route, not by filing tickets with yourself.
cert-manager holds the ACME relationship with Let’s Encrypt via a
ClusterIssuer in the wrapper chart, using the DNS-01 challenge through
Cloudflare, which is what makes the *.neozeon.ca wildcard certificate in
section 2 renewable forever without exposing an HTTP path:
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token-secret # from Vault, via ESO
key: api-token
Yes: even cert-manager’s Cloudflare API token is an ExternalSecret. There is no bootstrap exception, which is why this section is short.
external-dns reads Gateway API objects: gateway-httproute, gateway-tlsroute, gateway-tcproute, policy sync
and maintains the Cloudflare DNS records that mirror them. Create a route in
git, get a DNS record through the same tunnel the app uses; delete the route,
the record goes away. The cluster’s public surface and its DNS are the same
list, which is the kind of statement you should distrust in any blog post,
including this one.
The asterisk: the Cloudflare zone deliberately has no wildcard record. Every hostname is a real DNS entry (external-dns’s doing) plus a manual Cloudflare Tunnel route (mine). That manual step allows me to selectively expose an app over the internet versus just being available via a RFC1918 address on my LAN.
Wrapping Up
Step back and the architecture is almost aggressively boring: one repo that a generator matrix fans out into thirty-plus apps, one CNI wearing four hats, one Gateway, one Vault, one NAS, one wildcard certificate. The interesting parts aren’t components, they’re the contracts: the allowlist that makes absence mean something, the wrapper chart that pins one version while upstream drifts, the ExternalSecret that makes a credential deployable by ArgoCD, and the selfHeal that makes every operator habit (like hot-patching) impossible to form.
Boring is the goal. Boring is what “production” costs when you run it on mini-PCs in my basement and want the weekend back.
kubernetes gitops argocd cilium vault homelab
1701 Words
2026-10-18 19:30 (Last updated: 2026-10-20 19:15)