Kubernetes the Hard Way, Part 6: GitOps by Hand, a Reconcile Loop With Git and kubectl
On this page
GitOps sounds like a product you have to install. It is really one idea. The state you want lives in a Git repository, and a loop keeps comparing the cluster with that repository and fixing every difference. Flux and Argo CD are polished, battle-tested versions of that loop.
In this part you build the loop yourself, on the cluster from Part 1. You will set up a Git repository, give a reconciler its own narrow identity, and run it every 30 seconds with systemd. Then you will use it the way a team does. You will change the cluster through a commit, watch it undo a manual change, delete something by deleting a file, roll back with git revert, and push a broken commit to see what the loop does with it.
We ran every command in this post, top to bottom, on fresh machines before publishing it, and the outputs on this page are the ones we got.
What you need
The cluster from Part 1, with its jumpbox and server and the files in ~/kthw. Git is the only new package.
| Component | Version |
|---|---|
| Kubernetes and kubectl | v1.37.1 |
| Git | Debian 13 package |
One kubectl feature in this part is still marked alpha. --applyset, which decides what to prune, only works with KUBECTL_APPLYSET=true set. You will see why it is worth it in step 6.
Step 1. A repository for the cluster
The repository lives on the server, as a bare repository with no working copy, the same kind GitHub keeps for you. You work in a clone on the jumpbox and push to it over SSH, which root on the jumpbox can already do.
apt-get update -qqDEBIAN_FRONTEND=noninteractive apt-get install -y -qq git >/dev/nullgit init --quiet --bare --initial-branch=main /srv/git/store.gitls /srv/git/store.gitbranchesconfigdescriptionHEADhooksinfoobjectsrefsapt-get update -qqDEBIAN_FRONTEND=noninteractive apt-get install -y -qq git >/dev/nullgit config --global user.name "Hard Way"git config --global init.defaultBranch main
git clone --quiet root@server:/srv/git/store.git ~/storecd ~/storemkdir manifests
cat > manifests/deployment.yaml <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata: name: webspec: replicas: 2 selector: matchLabels: app: web template: metadata: labels: app: web spec: containers: - name: nginx image: nginx:1.30 ports: - containerPort: 80EOF
cat > manifests/service.yaml <<'EOF'apiVersion: v1kind: Servicemetadata: name: webspec: selector: app: web ports: - port: 80EOF
cat > manifests/settings.yaml <<'EOF'apiVersion: v1kind: ConfigMapmetadata: name: settingsdata: currency: EUREOF
git add manifestsgit commit --quiet -m "Add the web app"git push --quiet origin maingit log --onelinewarning: You appear to have cloned an empty repository.653ec58 Add the web appThe manifests name no namespace. The reconciler decides that, so the same files could be applied to any namespace you point it at.
Step 2. An identity for the reconciler
The reconciler should be able to change the store namespace and nothing else. Give it a client certificate from the cluster’s CA, exactly like the identities in Part 1, and a role binding that grants the built-in edit role in that one namespace.
cd ~/kthw/pkiopenssl genpkey -quiet -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out gitops.keyopenssl req -new -key gitops.key -subj "/CN=gitops-reconciler" -out gitops.csrprintf 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=clientAuth\n' > gitops.extopenssl x509 -req -in gitops.csr -CA ca.crt -CAkey ca.key -days 365 -sha256 \ -extfile gitops.ext -out gitops.crt 2>/dev/nullrm gitops.csr gitops.ext
cd ~/kthw/configskubectl config set-cluster kthw --certificate-authority=../pki/ca.crt \ --embed-certs=true --server=https://127.0.0.1:6443 --kubeconfig=gitops.kubeconfigkubectl config set-credentials gitops-reconciler --client-certificate=../pki/gitops.crt \ --client-key=../pki/gitops.key --embed-certs=true --kubeconfig=gitops.kubeconfigkubectl config set-context default --cluster=kthw --user=gitops-reconciler --kubeconfig=gitops.kubeconfigkubectl config use-context default --kubeconfig=gitops.kubeconfig
kubectl create namespace storekubectl -n store create rolebinding gitops-reconciler --clusterrole=edit --user=gitops-reconciler
kubectl auth can-i --as=gitops-reconciler -n store create deploymentskubectl auth can-i --as=gitops-reconciler -n default create deployments || truekubectl auth can-i --as=gitops-reconciler -n store get secretskubectl auth can-i --as=gitops-reconciler create namespaces || true
ssh root@server mkdir -p /var/lib/gitopsscp -q gitops.kubeconfig root@server:/var/lib/gitops/gitops.kubeconfigssh root@server chmod 600 /var/lib/gitops/gitops.kubeconfigCluster "kthw" set.User "gitops-reconciler" set.Context "default" created.Switched to context "default".namespace/store createdrolebinding.rbac.authorization.k8s.io/gitops-reconciler createdyesnoyesWarning: resource 'namespaces' is not namespace scoped
noThe four can-i answers are the reconciler’s whole world. It may create Deployments in store, but not in default, and it may not create namespaces. The warning on the last one only says that a namespace belongs to no namespace, so kubectl asked about the cluster as a whole.
The third answer is worth a pause. The edit role can read Secrets in its namespace, so anyone who can push to this repository can, in effect, read those Secrets too. That is true of every GitOps tool, and it is why write access to a GitOps repository deserves the same care as access to the cluster.
Step 3. The reconcile loop
The loop is a short script and a systemd timer. Each run fetches the repository, moves its working copy to exactly what main says, and applies every manifest.
cat > /usr/local/bin/gitops-reconcile <<'EOF'#!/bin/bash# Make the store namespace match the main branch of the repository, once.set -euo pipefailREPO=/srv/git/store.gitWORK=/var/lib/gitops/storeexport KUBECONFIG=/var/lib/gitops/gitops.kubeconfigexport KUBECTL_APPLYSET=true
[ -d "${WORK}" ] || git clone --quiet "${REPO}" "${WORK}"git -C "${WORK}" fetch --quiet origingit -C "${WORK}" reset --quiet --hard origin/mainREVISION=$(git -C "${WORK}" log -1 --format='%h %s')
kubectl apply --namespace store --server-side --field-manager=gitops --force-conflicts \ --prune --applyset=configmap/gitops-applyset -f "${WORK}/manifests/"echo "in sync with ${REVISION}"EOFchmod 0755 /usr/local/bin/gitops-reconcile
cat > /etc/systemd/system/gitops-reconcile.service <<'EOF'[Unit]Description=Apply the store repository to the cluster
[Service]Type=oneshotExecStart=/usr/local/bin/gitops-reconcileEOF
cat > /etc/systemd/system/gitops-reconcile.timer <<'EOF'[Unit]Description=Reconcile the store repository every 30 seconds
[Timer]OnBootSec=30sOnUnitInactiveSec=30sAccuracySec=1s
[Install]WantedBy=timers.targetEOF
systemctl daemon-reloadsystemctl enable --now gitops-reconcile.timersystemctl start gitops-reconcile.servicejournalctl -u gitops-reconcile --no-pager -o cat | grep -v -E '^(Starting|Finished|gitops-reconcile.service)'Created symlink '/etc/systemd/system/timers.target.wants/gitops-reconcile.timer' → '/etc/systemd/system/gitops-reconcile.timer'.deployment.apps/web serverside-appliedservice/web serverside-appliedconfigmap/settings serverside-appliedin sync with 653ec58 Add the web appThree choices in that script do most of the work.
git reset --hard origin/mainmakes the working copy exactly what the branch says, whatever happened to it before. The loop never merges and never keeps local changes.--server-side --field-manager=gitops --force-conflictsapplies with server-side apply. The API server records which fields each manager set, and--force-conflictslets the reconciler take back a field that someone else changed. That is what turnsapplyinto a repair.--prune --applyset=configmap/gitops-applysetremoves objects that disappeared from the repository. kubectl labels everything it applies as part of the set and keeps the list on that ConfigMap, so it prunes only what it created and never touches anything else in the namespace.
OnUnitInactiveSec=30s starts the next run 30 seconds after the previous one ended, so two runs never overlap.
Step 4. Change the cluster through Git
From now on, nobody changes the store namespace directly. You change the repository. Scale the deployment to three replicas with a commit.
cd ~/storesed -i 's/replicas: 2/replicas: 3/' manifests/deployment.yamlgit commit --quiet -am "Scale web to 3 replicas"git push --quiet origin main
until [ "$(kubectl -n store get deployment web -o jsonpath='{.spec.replicas}')" = 3 ]; do sleep 5; donekubectl -n store rollout status deployment/web --timeout=120sssh root@server "journalctl -u gitops-reconcile --no-pager -o cat --since -2min | grep 'in sync with' | tail -1"deployment "web" successfully rolled outin sync with e8c9fd6 Scale web to 3 replicasYou never ran a kubectl command that changes anything. The cluster did what the commit said, and the reconciler’s log names the commit it applied. Every change to the namespace now has an author, a time and a message, and can be reviewed before it lands.
Step 5. Watch the loop undo a manual change
Now be the colleague who fixes something by hand at two in the morning. Scale the deployment and delete the Service directly with kubectl, then wait for one loop.
kubectl -n store scale deployment web --replicas=6kubectl -n store delete service webkubectl -n store create configmap made-by-hand --from-literal=note=not-in-git
sleep 45kubectl -n store get deployment webkubectl -n store get service webkubectl -n store get configmapsdeployment.apps/web scaledservice "web" deleted from store namespaceconfigmap/made-by-hand createdNAME READY UP-TO-DATE AVAILABLE AGEweb 3/3 3 3 79sNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEweb ClusterIP 10.32.0.180 <none> 80/TCP 17sNAME DATA AGEgitops-applyset 0 79skube-root-ca.crt 1 82smade-by-hand 1 45ssettings 1 79sWithin one loop, the deployment is back to the three replicas the repository asks for, and the Service exists again, 17 seconds old, with a new address. The hand-made ConfigMap is still there. The reconciler repairs what it manages and leaves everything else alone, because only objects in its apply set are its business.
That is GitOps’ answer to the two-in-the-morning fix. It does not survive. If the fix was right, it belongs in a commit.
Step 6. Delete something by deleting a file
Remove the ConfigMap from the repository.
cd ~/storegit rm --quiet manifests/settings.yamlgit commit --quiet -m "Remove the settings ConfigMap"git push --quiet origin main
until ! kubectl -n store get configmap settings >/dev/null 2>&1; do sleep 5; donekubectl -n store get configmapsssh root@server "journalctl -u gitops-reconcile --no-pager -o cat --since -2min | grep -E 'pruned|in sync with' | tail -2"NAME DATA AGEgitops-applyset 0 96skube-root-ca.crt 1 99smade-by-hand 1 62sconfigmap/settings prunedin sync with dbaf88c Remove the settings ConfigMapThe file is gone, so the object is gone. Without pruning, a plain kubectl apply would only ever add and change things, and a cluster managed that way slowly fills up with objects nobody remembers. Notice that made-by-hand survived this too.
Step 7. Roll back with git revert
Undoing a change is a commit too. git revert writes a new commit that reverses an old one, so the history keeps both.
cd ~/storegit revert --no-edit HEAD >/dev/nullgit push --quiet origin maingit log --oneline
until kubectl -n store get configmap settings >/dev/null 2>&1; do sleep 5; donekubectl -n store get configmap settings -o jsonpath='{.data.currency}'; echoa0f6766 Revert "Remove the settings ConfigMap"dbaf88c Remove the settings ConfigMape8c9fd6 Scale web to 3 replicas653ec58 Add the web appEURThe ConfigMap is back with its data, and the log tells the whole story. A rollback in GitOps is not a special command. It is a commit that happens to restore an older state, reviewed and recorded like any other.
Step 8. Push a broken commit
A typo in a manifest is the most common GitOps incident. Push one on purpose.
cd ~/storesed -i 's/replicas: 3/replicas: three/' manifests/deployment.yamlgit commit --quiet -am "Scale web, with a typo"git push --quiet origin main
sleep 45ssh root@server "systemctl is-failed gitops-reconcile.service; journalctl -u gitops-reconcile --no-pager -o cat --since -40s | grep -E 'applied|pruned|Error from server|Failed with result' | tail -4" || truekubectl -n store get deployment webfailedservice/web serverside-appliedconfigmap/settings serverside-appliedError from server: failed to create typed patch object (store/web; apps/v1, Kind=Deployment): .spec.replicas: expected numeric (int or float), got stringgitops-reconcile.service: Failed with result 'exit-code'.NAME READY UP-TO-DATE AVAILABLE AGEweb 3/3 3 3 2m56sThe API server rejected the Deployment, because three is not a number. The service is marked failed, and the deployment keeps running its three replicas, untouched. Read the lines above the error carefully, though. The Service and the ConfigMap were applied in the same run. kubectl applies each object on its own, so one bad object does not stop the others. It did stop the prune, because kubectl does not prune after an error.
The loop keeps retrying every 30 seconds and failing the same way until the repository is fixed. A real setup alerts on that failed state. Here, systemctl is-failed is the alarm.
Fix it the GitOps way, with another commit.
cd ~/storegit revert --no-edit HEAD >/dev/nullgit push --quiet origin mainsleep 45ssh root@server "systemctl is-failed gitops-reconcile.service || true; journalctl -u gitops-reconcile --no-pager -o cat --since -1min | grep 'in sync with' | tail -1"inactivein sync with d09e364 Revert "Scale web, with a typo"inactive means the last run succeeded and the next one is waiting for its timer.
When something does not work
E: Unable to locate package git. The machine’s package lists are empty, which is normal on a fresh cloud image. Runapt-get updatefirst, as the blocks above do.Error from server: failed to create typed patch object ... expected numeric, and the service isfailed. A manifest in the repository is invalid. Find it in the error, fix it with a commit, and the next run recovers.- A manual change keeps coming back, or keeps disappearing. That is the loop working. Change the repository instead, or take the object out of the repository if a person should own it.
warning: You appear to have cloned an empty repository.That is expected for the very first clone, before anything was pushed.
What a real GitOps tool adds
The loop you built is real GitOps, and it is about 20 lines. Flux and Argo CD do the same thing, plus what a team needs once more than one person relies on it.
- They run inside the cluster as controllers, pull from a hosted Git service and need no SSH access to a machine.
- They know about health. A sync is not done when
applyreturns, but when the new pods are ready, and a sync whose pods never become ready is reported as failed. - They order things, such as a namespace and its CRDs before the objects that need them.
- They handle Secrets, which do not belong in Git in plain text. Encrypting them with SOPS is one answer, and this post on SOPS and age shows how.
- They report and alert, with a status per application, the commit it runs, and a notification when a sync fails.
The easy way, once you know the hard way
Ankra puts this loop under every cluster it manages, without a script on any machine. A cluster’s stacks, its add-ons, manifests and settings, are stored in a Git repository you own, on GitHub or Bitbucket Cloud, one directory per cluster, and Ankra’s engine keeps cluster and repository in sync in both directions. A change in the portal becomes a commit, and a merged pull request becomes a change in the cluster. Wire the repository when Ankra builds the cluster.
ankra cluster hetzner create --name my-cluster --location hel1 \ --credential-id <your-hetzner-credential> \ --gitops-repository my-org/platform-gitops \ --gitops-credential-name my-github-credential --gitops-branch mainankra cluster gitops status my-clusterankra cluster gitops status shows what your in sync with line showed here, and more. It names the repository and branch, the last commit applied, and any sync error. When the same object is changed in Git and in the portal at once, Ankra pauses the sync and shows you the conflict instead of letting one side win silently.
This is Part 6 of Kubernetes the Hard Way, a series of guides we test end to end before publishing. A part on upgrading Kubernetes by hand follows when Kubernetes 1.38 is released.
Get started: Create a free account on Ankra.
Join our community: Slack
Follow us on: LinkedIn | GitHub
Contact us: [email protected]
Get the next post in your inbox
Related Posts
Kubernetes the Hard Way, Part 3: Back Up etcd and Restore a Lost Control Plane
Back up a Kubernetes 1.37 cluster by hand with etcd snapshots, then delete the control plane and restore it. Includes the encryption key trap, all tested.
Kubernetes the Hard Way, Part 5: Certificates, Expiry and Renewal Without Downtime
Read every Kubernetes certificate, watch one expire, and renew them all by hand. Most components reload a renewed certificate with no restart at all. Tested.