Infrastructure engineering for East Africa's operators, platforms and regulators See the work →
Argo CD Setup: Auto-Sync, Self-Heal and Prune, Tested
DevOps 8 min read

Argo CD Setup: Auto-Sync, Self-Heal and Prune, Tested

Argo CD setup on its defaults keeps drift and orphaned resources. Our v3.5.5 lab timed auto-sync, self-heal and prune, and what GitOps still will not fix.

CE
CloudSpinx Engineering
Infrastructure Engineering, Virtualization, Networking, Automation
17 October 2026
argo cdgitopskubernetesargo cd self heal

A commit we pushed to a fresh Argo CD setup took 5 minutes 42 seconds to reach the cluster. The documentation says three. That was the second of four pushes to Argo CD v3.5.5 with automated sync on, and it was not the most surprising thing the defaults did that morning.

CloudSpinx sets up and runs GitOps with Argo CD for engineering teams in Kenya and across East Africa, as part of our DevOps and CI/CD practice, so we timed the defaults on our own Proxmox lab before writing about them: one k3s node, a bare Git repository, no webhook, and a clock around every step. The short version: automated: {} applies commits and little else, prune and self-heal fix most of that with a catch of their own, and three problems survive any sync policy.

What does Argo CD's automated sync do by default?

It syncs when Git or the Application itself changes, not when the cluster does. Prune and self-heal both stay off until you set them, which the auto-sync documentation states plainly and a running cluster makes easy to forget. Here is our run, automated: {} against the same Application with both switched on:

What happenedautomated: {}with prune and selfHeal
Commit pushed to main, no webhookApplied after 139 to 342 s, four pushesNot retimed: the poll is a controller setting
kubectl scale from 1 to 5 replicasStill 5 after 210 s, app shown OutOfSyncReverted in 1.6 s
Deployment deleted from GitStill running, flagged as needing a pruneDeleted 0.8 s after prune was enabled

The poll is the first number to plan around. The Argo CD FAQ puts it at 120 seconds plus up to 60 seconds of jitter, and our four pushes landed after 139, 342, 249 and 335 seconds. The three slow ones each came less than 20 seconds after the previous sync, and the refresh that followed about two minutes later still generated manifests for the previous commit, straight from cache. The repo server keeps a resolved revision for 3 minutes by default (--revision-cache-expiration), so on our reading a refresh that comes within 3 minutes of the last one that fetched the branch still sees the old commit, and the push waits for the refresh after it. The one push that went out first time was the one whose refresh came 3 minutes 3 seconds after the previous sync, just past that expiry.

A webhook to /api/webhook removes the wait, if GitHub or GitLab can reach your Argo CD server. A cluster in an office rack or a Nairobi facility behind NAT, with no inbound route, gets no webhook without a relay or a tunnel, so the poll is your deploy time. Nearly six minutes is fine for most teams on most days. It is not fine during an incident, which is where the defaults bite hardest.

The hotfix an unrelated merge undoes

Somebody runs kubectl scale deploy web --replicas=5 to ride out a spike. With self-heal off, Argo CD marks the app OutOfSync and leaves the five replicas alone. We watched for three and a half minutes and nothing moved, so the team reasonably concludes the fix holds.

Then a colleague merges a one-line ConfigMap change. Automated sync applied the whole application at the new commit, not just the ConfigMap, and web fell back to one replica 71 seconds after that push. Nobody chose that moment, and the person whose merge did it never touched the Deployment.

How an unrelated Argo CD sync undid a kubectl hotfix
How an unrelated Argo CD sync undid a kubectl hotfixTwo lanes on one scale of seconds after somebody scaled a Deployment from 1 to 5 replicas by hand. With automated sync on and self-heal off, Argo CD marked the application OutOfSync and left the 5 replicas alone for 210 seconds. A ConfigMap commit that did not touch the Deployment was pushed at 211 seconds, and the sync that applied it at 283 seconds also applied the Deployment, so it went back to 1 replica. With self-heal on, the same hand edit was reverted after 1.6 seconds.AUTOMATED: {} · SELF-HEAL OFF, THE DEFAULT5 replicas, OutOfSync, left alonenext refresh0skubectl scaleto 5 replicas211sConfigMapcommit pushed283ssync: back to1 replicaCOMPARE · SELF-HEAL ON1 replica, as Git says1.6ssame hand edit reverted
  • drift Argo CD reports and leaves
  • hand edit removed
  • live state matches Git
With self-heal off a hand edit is not kept, its removal is only postponed to the next commit: it stood for 4 minutes 43 seconds and ended with a commit that never touched the Deployment.

A sync applies the whole application at the new commit, not only what the commit changed, so the drifted Deployment went back to Git's value with the ConfigMap.

A laptop under a desk lamp in a dark room with code on the screen, the late-night kubectl fix GitOps has to account for

That is the worst of the default: drift tolerated long enough for people to rely on it, then removed without warning. Either let Argo CD revert drift at once, which turns every hotfix into a commit, or hand the field over properly: an ignoreDifferences entry plus the RespectIgnoreDifferences=true sync option, because the ignore list alone does not stop the next sync overwriting it. If you cannot say which of your Applications sit in between, send us the list and we will mark them.

Self-heal and prune: the Argo CD setup we hand over

With both on, Argo CD deleted the orphaned legacy-worker 0.8 seconds after we patched the Application, and reverted a kubectl scale in 1.6 seconds. So we scaled it again the moment it came back, and again. The second revert took 5.2 seconds, the third 17.8, the fourth 53.8, the fifth 162.4 and the sixth 300.9.

Argo CD self-heal waits longer each time the same drift comes back
Argo CD self-heal waits longer each time the same drift comes backSix bars, one per hand edit with self-heal on, each after the first made the moment Argo CD had reverted the previous one. The reverts took 1.6, 5.2, 17.8, 53.8, 162.4 and 300.9 seconds, roughly tripling each time until they hit a five-minute ceiling. That matches the v3.5.5 controller defaults: a backoff starting at 2 seconds, multiplied by 3 per attempt, capped at 300 seconds.drift 11.6 sdrift 25.2 sdrift 317.8 sdrift 453.8 sdrift 5162.4 sdrift 6300.9 scap 300 s0 s
Self-heal is a backoff, not a lock: the sixth revert of the same drift took 300.9 seconds.

The wait counts from the last sync. After five and a half quiet minutes, past the 300-second cap, the next revert of the same drift took 1.0 second.

The auto-sync page still describes a fixed 5-second self-heal timeout. The v3.5.5 controller says otherwise, and you can read it without a cluster: the released argocd-linux-amd64 CLI binary behaves as the controller when it is called by the controller's name.

$ ln -s argocd-linux-amd64 argocd-application-controller
$ ./argocd-application-controller --help | grep -o -- '--self-heal-backoff.*' | sed 's/ int .*(default \([0-9]*\))/ = \1/'
--self-heal-backoff-cap-seconds = 300
--self-heal-backoff-factor = 3
--self-heal-backoff-timeout-seconds = 2

Start at 2 seconds, triple on each attempt, stop at 300, which is the bars above to within a second. The --self-heal-timeout-seconds flag the docs refer to is still listed, with no default printed.

Self-heal is a convergence loop, then, not a lock. Anything that keeps rewriting a field Git also sets gets fought on that backoff, and on our reading of the v3.5.5 source the attempt count does not reset while the commit stays the same, so the fight settles at one revert every five minutes instead of ending. We did not run an autoscaler against it, but a HorizontalPodAutoscaler on a Deployment whose manifest still says replicas is exactly that shape. Kubernetes' autoscaling documentation already warns that applying such a manifest can end in "thrashing or flapping behavior", and self-heal turns one apply into a recurring one. Take replicas out of the manifest, and mind the catch the same page spells out: the field defaults to 1, so the first apply after you remove it can drop the Deployment to a single Pod, and the page gives a way around it for both client-side and server-side apply.

So the Application we hand over sets both, plus a retry it chose rather than inherited:

  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    retry:
      limit: 3
      backoff:
        duration: 10s
        factor: 2
        maxDuration: 3m
    syncOptions:
      - CreateNamespace=true
      - PruneLast=true

None of this is our invention. In Mojaloop's infrastructure code, which we contribute to upstream, 40 of the 41 Applications in gitops/argo-apps/base set prune, self-heal and a retry policy. The 41st installs Argo CD itself and leaves both at false, the in-between state described above for everything except two ConfigMap keys. The manifest gives no reason; ours would be that an over-eager prune there could delete the controller doing the pruning. We would go a step further on that one and drop automated sync too, so a change to the controller waits for a person.

Public code like that is also a check you can run on anyone you hire for this, and it is the first one in how we would judge a DevOps consultant.

What GitOps does not fix

Three things, whatever the sync policy says.

A Secret committed to Git is base64, an encoding anyone with read access to the repository reverses in one command, and under GitOps everyone who deploys reads that repository. Keep the value out of it. External Secrets Operator syncs it into the cluster from a vault, and Git holds only the reference.

Database migrations are the second. We put one in a PreSync hook Job, made it exit 1 in the same commit as a version bump, and set no retry policy. The sync failed at the hook and web stayed on the previous version, which is the part Argo CD gets right. Then it ran the migration five more times, backing off 10, 20, 40, 80 and 160 seconds, before giving up. Automated syncs carry a retry limit of 5 unless you set one: the v3.5.5 controller source hard-codes it, and the auto-sync documentation does not state it. An explicit retry replaces it, and with limit: 3 the same migration ran four times. So a migration that is not safe to re-run gets re-run anyway. One that succeeds is not undone by git revert either, because Argo CD reverts manifests, not schemas. Write migrations that survive running twice and only move forward, and set the retry limit on purpose.

The third is the drift you choose to allow. An ignoreDifferences entry on its own only hides a field from the diff, and the next sync still writes Git's value over it. Paired with RespectIgnoreDifferences=true it becomes a field Git no longer owns, so review that list the way you would review firewall rules. Mojaloop's Argo CD Application ignores two keys in its own ConfigMaps, policy.csv and oidc.config, with that option set beside them. Written down in the repository, where a reviewer sees it: that is the standard.

GitOps vs CI/CD: what moves out of the pipeline

Moving a small team from push deploys to Argo CD changes less than the diagrams suggest, and the change that matters is credentials. A pipeline that runs kubectl apply or helm upgrade holds a kubeconfig that can write to production, stored as a CI secret. Under Argo CD the controller inside the cluster pulls, so CI no longer needs the cluster at all, only write access to a config repository, and the pipeline's last step becomes a commit that bumps an image tag. Rollback becomes a revert of that commit, for manifests at least. The build and test stages, and the runner minutes they spend, stay where they were.

What the team takes on is a second repository to guard, the poll or a webhook to expose, and the controller itself: on our lab its seven pods used between 329 and 358 MiB of memory with one Application, 118 MiB of that the Dex single sign-on server. If you would rather not run the cluster under it either, that is a separate engagement: managed Kubernetes.

If you run one service on one cluster and deploy weekly from a pipeline that works, keep the pipeline. Argo CD would add a controller to patch and a repository to protect, for a problem you do not have yet.

If you already run Argo CD, export your Applications with kubectl get applications -A -o yaml and send the file through the DevOps scoping form or on WhatsApp at +254 713 403 044. We read every sync policy and mark each Application that keeps drift, keeps orphans or will re-run a failed migration. The review is free and there is nothing to sign. If an inline Helm value in that file turns out to hold a password, that becomes the first line of our reply. Starting from push pipelines instead, the same form gets you a scope and one figure for standing Argo CD up, and we move your deploys across with your team rather than handing over a controller and leaving.

WhatsApp