This project started with a simple goal: run Kubernetes without keeping an EKS
cluster online every day.
In I Wanted Kubernetes Without an Always-On EKS Bill,
I built an always-on k3s lab on my home server and proved that I could deploy,
update, and roll back an application.
The rollback worked, but it exposed the next problem. Kubernetes restored
Version 2 while the saved YAML still declared Version 3. I corrected the file
manually, but the recovery depended on repairing the running cluster and its
saved instructions separately.
In The Rollback Worked. My Next Deploy Could Break It Again,
I designed a safer path. The automated build process would test and publish an
exact image, then stop at a Git pull request. Git would record the reviewed
version. Argo CD, running inside Kubernetes, would make the cluster follow that
record.
Now I needed to prove that the design worked outside a diagram.
I followed one release from source code to running Pods. Then I tested two
opposite failures:
- The cluster was wrong while Git was correct.
- Git contained a bad setting while the cluster followed it correctly.
Those experiments showed both the value and the limit of GitOps. Automation can
make the cluster match Git, but it cannot decide whether the human-approved
version in Git is a good one.
CI Built the Release but Did Not Deploy It
The GitHub Actions workflow—my continuous integration, or CI, worker—ran the
application tests and checked the Kubernetes package before building anything.
Its job was to prove and publish a release, not to change the cluster.
After validation, Buildx created a Linux AMD64 image with the full source commit
baked into /version:
docker buildx build \
--platform linux/amd64 \
--build-arg "APP_VERSION=$GITHUB_SHA" \
--tag "$image_name:$GITHUB_SHA" \
--provenance=mode=max \
--sbom=true \
--push \
application
After publishing the image, CI read its registry digest. A digest is the image's
content fingerprint: if the image changes, the digest changes. CI updated one
field in gitops/environments/homelab/values.yaml. For the final measured
release, the automated pull request contained this one-line change:
- digest: sha256:170bcc91b9c4bfcbdc194c004cb38d6c89094e9b49a1a128ed3a0507976fef97
+ digest: sha256:f8303e07e737f40fa5479b73a148261cf4221f4d4b52ebf095b74d2cb4ef5be5
The workflow opened the pull request and stopped. A human reviewed and merged
the digest. That merge—not a deployment command in CI—was the release
instruction.
Argo CD Made the Cluster Follow Git
Argo CD was the worker inside Kubernetes. It repeatedly compared the version
recorded in Git with the version running in the cluster and worked to make them
agree. This comparison-and-correction loop is called reconciliation.
I installed a pinned, resource-limited Argo CD Core deployment with no permanent
API server or public UI. Its Services remained internal ClusterIP Services.
The Application reads both the Helm chart and homelab values from the public
repository. Automated sync, pruning, and self-healing are enabled:
syncPolicy:
automated:
enabled: true
prune: true
selfHeal: true
At the healthy baseline, Argo CD reported Synced, Healthy, and operation
Succeeded. Two application Pods were ready with zero restarts.
I retained the complete artifact trace:
application commit
-> registry digest
-> Git desired state
-> Deployment image
-> both Pod runtime image IDs
-> /version response
The Git declaration, Deployment, and both Pod image IDs agreed on the same
registry digest. /version returned the source commit baked into that image.
The four Argo CD components used a combined 19 millicores of CPU and 85 MiB of
memory in the retained steady-state sample. That measurement describes this
home lab; it is not production sizing guidance.
First, I Changed the Cluster Behind Git’s Back
Git declared two replicas. I deliberately changed the live Deployment to one:
kubectl -n delivery-api scale deployment/delivery-api --replicas=1
The first poll already showed one replica and Argo CD OutOfSync. Git remained
unchanged with replicaCount: 2.
Forty-two seconds after the mutation, the Deployment was back to two replicas
and the Application was Synced again.
This proved self-healing for that specific field. Git remained correct, so Argo
CD could restore the live cluster from the durable declaration.
The next failure reversed that relationship.
Then I Put a Bad Setting in Git
I merged a reviewed configuration change from:
readinessMode: ready
to:
readinessMode: fail
The change was valid YAML. It passed the Helm schema and lint checks. At runtime,
the new Pod stayed alive but /ready returned HTTP 503.
Forty-nine seconds after the merge, the evidence showed:
Argo CD sync: Synced
Argo CD health: Progressing
New Pod: Running, not Ready
Old Pods: Running, Ready
Synced meant the cluster matched Git. Progressing meant Kubernetes could not
complete the rollout.
GitOps had not malfunctioned. It had correctly delivered a harmful declaration.
The Deployment used maxUnavailable: 0, so the two old ready replicas remained
while the replacement failed readiness. One sampled request through the private
Service returned {"status":"ready"}. That proves availability at that moment,
not zero downtime across the experiment.
Recovery Had to Correct Git
Patching only the live Deployment would leave Git declaring the bad readiness
mode. Argo CD could then reapply it.
I reverted the bad merge in Git:
git revert --mainline 1 69ac4fe6de392aed4affc573ea20a2bb3f4ee59f
git push
Argo CD pulled the revert and restored readinessMode: ready. Forty-two seconds
after the recovery commit, the Application was Synced and Healthy with two
updated, ready replicas.
Argo CD did not choose the known-good version. I chose it by reverting Git; Argo
CD reconciled that decision.
I Timed a Fresh Release End to End
The first image promotion happened before Argo CD was installed, so I did not
use it for a continuous promotion-to-healthy claim. I ran another application
release with Argo CD already active and started the monitor before merging the
digest pull request.
| Event | Observed result |
|---|---|
| CI workflow window | 74 seconds |
| Validation job | 14 seconds |
| Image publication and promotion job | 50 seconds |
| Promotion merge to Argo CD operation start | 32 seconds |
| Argo CD operation start to healthy | 11 seconds |
| Promotion merge to healthy release | 43 seconds |
The monitor sampled every two seconds, so the healthy timestamp has up to two
seconds of sampling uncertainty.
During the rolling update, the Deployment already declared the new digest while
a request still reached an old ready Pod and returned the previous commit. A few
seconds later, both updated replicas were ready and /version returned:
{"service":"delivery-api","version":"84db89f7f968c5860b07a7efd6a42e21895d9e5c"}
That was expected. A rolling update changes replicas gradually; the Deployment
declaration and every serving Pod do not switch at the same instant.
What the Implementation Proved
Application CI published the artifact and proposed the environment change
without receiving cluster authority. Git retained the reviewed digest. Argo CD
pulled and reconciled it. Kubernetes reported rollout health.
The failure tests also exposed the limits:
- Self-healing corrected live drift only because Git remained correct.
- Argo CD faithfully applied the bad readiness declaration from Git.
- Recovery required a human decision recorded as a Git revert.
- One successful request did not prove zero request loss.
- A single-node k3s cluster could not demonstrate high availability.
GitOps did not remove operational judgment. It moved release and recovery
decisions into a place where they were reviewable, durable, and reconcilable.
Conclusion
The first article created an affordable, always-on Kubernetes lab. The second
used its rollback problem to design one reviewable release path. This
implementation proved how that path behaved when either the cluster or Git was
wrong.
The common lesson across the three articles is that a working cluster is only
one part of a delivery platform. The approved release, permission to deploy,
health signals, and recovery decision also need clear owners.
The code, chart, GitOps configuration, runbooks, and sanitized evidence are in
the public
pull-based-kubernetes-delivery repository.
This article was originally published by DEV Community and written by Lalit Bagga.
Read original article on DEV Community