Argo CD and Helm · Lesson 4 of 11 · about 30 minutes

Sync, drift and self-healing

The bookshop gets a real storefront, through a pull request, and the pods take over a minute to show it. Then sync taken apart: prune deleting what Git no longer has, prune switched off and a Service left behind, sync switched to manual so a person decides, and the catch in changing an Application with the CLI.

By the end you'll know

  • How long a change really takes to reach users, from merge to pods
  • What prune does when a file is deleted from Git, and what happens without it
  • What selfHeal adds on top of automated sync
  • How manual sync works, and when a person should press the button
  • Why changing an Application with the CLI leaves its file out of date

Before you start

  • You need: Lessons 1 to 3 done, with the bookshop Application synced from your repository.
  • Get the new files: git pull. You'll have site/index.html, the new storefront page, and a new apps/bookshop/configmap.yaml made from it.
  • Where commands run: your own computer's terminal, in gitops-practice, logged in with argocd login as in Lesson 2.

The short version

  • A real page. The two-line page became a storefront, through a pull request. Argo CD synced it in 2 seconds after a refresh. The pods served the old page for over a minute after that, because a ConfigMap change doesn't restart them.
  • Prune. A Service added in Git appeared; deleted from Git, it was deleted from the cluster. With prune off, it stayed, and Argo CD marked it OutOfSync until a person pruned it.
  • Manual sync. With automated sync off, a price change waited in Git, OutOfSync, until someone ran argocd app sync.
  • The catch. Each of those settings was changed with the CLI, so the Application in the cluster no longer matched its own file. Lesson 8 fixes that for good.

Three settings, three questions

The bookshop's Application has had the same sync policy since Lesson 3:

YAML
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Three settings, and each answers a different question:

  • automated: when Git changes, does Argo CD apply it by itself, or wait for a person?
  • selfHeal: when the cluster changes by hand, does Argo CD put it back? Lessons 1 and 2 covered this one.
  • prune: when something is removed from Git, does Argo CD remove it from the cluster too?

Think of a gardener with a plan of the garden. automated is whether they plant what's on the plan without asking. selfHeal is whether they replant what someone dug up. And prune is whether they pull out a plant that's no longer on the plan, or leave it, because maybe someone wanted it. This lesson tries each.

A real storefront

First, the page. Lesson 3's page was two lines of HTML. site/index.html is a proper storefront: a header, a shelf of books, six staff picks with prices and stock, and a footer that says which version is running.

BASH
wc -c site/index.html
OUTPUT
    7485 site/index.html

7 KB, styles and all, in one file. It isn't Kubernetes YAML, so it lives outside apps/bookshop/, and the ConfigMap is made from it:

BASH
kubectl create configmap bookshop-page --from-file=index.html=site/index.html --dry-run=client -o yaml

--dry-run=client -o yaml doesn't touch the cluster: it prints the ConfigMap kubectl would make, and that output becomes apps/bookshop/configmap.yaml. So site/index.html is the file you edit, and the ConfigMap is regenerated from it. Then the usual way: a branch, a pull request, merged.

OUTPUT
merged: 4873922 Bookshop: a real storefront page (#3)

This time, instead of waiting for Argo CD's timer, a refresh straight after the merge:

BASH
argocd app get bookshop --refresh
OUTPUT
synced to 4873922 after 2s

Two seconds. Compare Lesson 3's 265. So is the new page live? Checking every 10 seconds whether each pod's file has the new page in it (1 or more means yes, 0 means still the old page):

OUTPUT
after 10s:
bookshop-8467b4689d-fdb9p: 0
bookshop-8467b4689d-mkm6t: 0
...
after 60s:
bookshop-8467b4689d-fdb9p: 0
bookshop-8467b4689d-mkm6t: 0
after 70s:
bookshop-8467b4689d-fdb9p: 3
bookshop-8467b4689d-mkm6t: 0
after 80s:
bookshop-8467b4689d-fdb9p: 3
bookshop-8467b4689d-mkm6t: 3

Synced in 2 seconds; seen by users in 70 to 80. For over a minute, Argo CD said Synced, the cluster's ConfigMap had the new page, and anyone visiting the shop saw the old one. Then the two pods switched, ten seconds apart, without restarting:

OUTPUT
NAME                        READY   STATUS    RESTARTS   AGE
bookshop-8467b4689d-fdb9p   1/1     Running   0          3h43m
bookshop-8467b4689d-mkm6t   1/1     Running   0          3h48m

Hours old, the same pods as Lesson 3. That's the delay Lesson 3 ran into, measured: Kubernetes refreshes a mounted ConfigMap's files on its own schedule, roughly every minute. Fine for a page; a problem for anything that needs the change at once, which is what Lesson 6's checksum fix is for.

Prune: deleted from Git, deleted from the cluster

Add something, then remove it. A second Service, bookshop-preview, in its own file:

YAML
# A second way in, for previewing the shop. Removed again in this lesson.
apiVersion: v1
kind: Service
metadata:
  name: bookshop-preview
spec:
  selector:
    app: bookshop
  ports:
    - port: 8080
      targetPort: 80

Merged, refreshed:

OUTPUT
merged: 1c30a8e Bookshop: a preview Service (#4)
synced to 1c30a8e after 1s

NAME               TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
bookshop           ClusterIP   10.96.141.31   <none>        80/TCP     3h48m
bookshop-preview   ClusterIP   10.96.16.39    <none>        8080/TCP   0s

There it is. Now delete the file, with git rm, and merge that:

OUTPUT
merged: 1eacdb7 Bookshop: remove the preview Service (#5)
synced to 1eacdb7 after 0s

NAME       TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
bookshop   ClusterIP   10.96.141.31   <none>        80/TCP    3h48m

Gone from the cluster too. That's prune: true. Argo CD knows the Service was its own, from the tracking annotation Lesson 2 showed, so when Git stops mentioning it, Argo CD deletes it. The same thing the Ansible track had to do by hand with state: absent, done by deleting a file.

Prune switched off

Same again, with prune off:

BASH
argocd app set bookshop --auto-prune=false
OUTPUT
{'automated': {'prune': False, 'selfHeal': True}, 'syncOptions': ['CreateNamespace=true']}

The preview Service goes back in (#6), then is removed again (#7). The cluster:

OUTPUT
NAME               TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
bookshop           ClusterIP   10.96.141.31   <none>        80/TCP     3h48m
bookshop-preview   ClusterIP   10.96.73.110   <none>        8080/TCP   20s

Still there, though Git no longer has it. And Argo CD says so:

BASH
argocd app get bookshop
OUTPUT
Sync Status:        OutOfSync from main (90dcbf0)
Health Status:      Healthy

GROUP  KIND        NAMESPACE  NAME              STATUS     HEALTH   HOOK  MESSAGE
       Service     bookshop   bookshop-preview  OutOfSync  Healthy        service/bookshop-preview unchanged
       ConfigMap   bookshop   bookshop-page     Synced                    
       Service     bookshop   bookshop          Synced     Healthy        
apps   Deployment  bookshop   bookshop          Synced     Healthy        

The whole app is OutOfSync, and the table shows why: one Service, OutOfSync, that Git doesn't have. Automated sync did run, and synced everything else, but it won't delete anything without permission. A person gives it:

BASH
argocd app sync bookshop --prune
OUTPUT
GROUP  KIND        NAMESPACE  NAME              STATUS     HEALTH   HOOK  MESSAGE
       Service     bookshop   bookshop-preview  Succeeded  Pruned         pruned
       ConfigMap   bookshop   bookshop-page     Synced                    configmap/bookshop-page configured
       Service     bookshop   bookshop          Synced     Healthy        service/bookshop unchanged
apps   Deployment  bookshop   bookshop          Synced     Healthy        deployment.apps/bookshop unchanged

Pruned. So which is right? Prune on means Git is the whole truth, and deleting a file is a real delete. Prune off protects against deleting by accident, such as a folder renamed in a pull request, at the cost of leftovers someone has to clean up. Most teams run with prune on, and protect the few things that must never be deleted, like a database's storage, with a per-resource setting instead.

Manual sync: a person decides

The last setting: no automated sync at all.

BASH
argocd app set bookshop --sync-policy none
argocd app get bookshop | grep "Sync Policy"
OUTPUT
Sync Policy:        Manual

Then a change in Git: The Linux Command Line drops from $29 to $24, in site/index.html and the regenerated ConfigMap.

OUTPUT
merged: 3e6701d Bookshop: The Linux Command Line, now $24 (#8)

Sync Status:        OutOfSync from main (3e6701d)
Health Status:      Healthy

Merged, noticed, and not applied. Argo CD knows Git has moved on, and waits. What would change?

BASH
argocd app diff bookshop
OUTPUT
===== /ConfigMap bookshop/bookshop-page ======
<             <div class="row"><span class="price">$29</span><span class="stock">4 in stock</span></div>
>             <div class="row"><span class="price">$24</span><span class="stock">4 in stock</span></div>
diff exit code: 1

(Just the price lines; the full diff is the whole page.) The cluster says $29, Git says $24. A person reads that, and decides:

BASH
argocd app sync bookshop
argocd app get bookshop | grep -E "Sync Status|Sync Policy"
OUTPUT
GROUP  KIND        NAMESPACE  NAME           STATUS  HEALTH   HOOK  MESSAGE
       ConfigMap   bookshop   bookshop-page  Synced                 configmap/bookshop-page configured

Sync Policy:        Manual
Sync Status:        Synced to main (3e6701d)

Manual sync is a reasonable choice for production at some teams: Git is still the only source, but a deploy also needs someone to press the button, after reading the diff. The cost is that merged changes can sit waiting, and someone has to remember.

The catch: the Application drifted from its file

Every setting in this lesson was changed with argocd app set. That changed the Application in the cluster. Its file in Git still says:

BASH
grep -A3 "syncPolicy" argocd/bookshop.yaml
OUTPUT
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

So the bookshop's contents are managed by GitOps, but the Application describing them isn't: it was applied by hand in Lesson 3, and nothing watches it. Right now the cluster says Manual, and the file says automated. Here, putting it back is one command:

BASH
kubectl apply -f argocd/bookshop.yaml
argocd app get bookshop | grep "Sync Policy"
OUTPUT
application.argoproj.io/bookshop configured
Sync Policy:        Automated (Prune)

But it's the same drift Argo CD exists to prevent, one level up. The fix is to let Argo CD manage the Applications themselves, from Git: an Application whose folder holds the other Applications. That's app-of-apps, Lesson 8. Until then, change Applications by editing their files and applying them, not with argocd app set; this lesson used the CLI to show each setting quickly.

Every change, in order

BASH
argocd app history bookshop
OUTPUT
ID      DATE                           REVISION
0       2026-11-03 19:17:20 -0500 EST  main (6d00a98)
1       2026-11-03 19:22:03 -0500 EST  main (c745b1f)
2       2026-11-03 23:04:17 -0500 EST  main (4873922)
3       2026-11-03 23:05:43 -0500 EST  main (1c30a8e)
4       2026-11-03 23:05:50 -0500 EST  main (1eacdb7)
5       2026-11-03 23:05:59 -0500 EST  main (abc1322)
6       2026-11-03 23:06:20 -0500 EST  main (90dcbf0)
7       2026-11-03 23:06:39 -0500 EST  main (3e6701d)

Eight deploys, each one a merged pull request, each one a commit you can read with git show.

In real systems

  • Prune on, with exceptions. A per-resource annotation, argocd.argoproj.io/sync-options: Prune=false, stops Argo CD ever deleting that one resource, such as a database's storage claim, while everything else is pruned normally.
  • Settings that need a restart get a checksum. For an app that reads its settings at startup, the minute-long ConfigMap delay becomes "never, until a restart". Helm charts put a checksum of the ConfigMap in the pod template, so changing settings rolls the pods. Lesson 6 does it.
  • Manual sync for production, automated for the rest. A common split: development and staging sync automatically on every merge, production waits for a person, or for a sync window that only allows deploys at certain times.
  • Applications live in Git too. Teams don't argocd app set in production. The Applications are files in a repository, managed by a parent Application, so their settings get reviews and history like everything else.

Spot the problem

Work out what's wrong before you read the explanation.

The tug of war. A team adds a HorizontalPodAutoscaler to the bookshop, so it scales between 2 and 10 copies with traffic. Their Deployment in Git still says replicas: 2, and the Application has selfHeal: true. On a busy afternoon, the autoscaler raises the Deployment to 6 copies. Seconds later there are 2 again. Then 6, then 2. The shop is slow, and the pods keep starting and stopping. What's happening? Argo CD and the autoscaler are fighting over the same field. To Argo CD, 6 copies is drift from Git's 2, and self-heal puts it back, exactly as it did with kubectl scale in Lesson 1. The autoscaler then raises it again. The fix: tell Argo CD that replicas isn't its business on this Deployment. Either remove replicas from the Deployment in Git, so the autoscaler owns it, or add an ignoreDifferences entry for /spec/replicas to the Application. One owner per field.

Keep this

ToWrite or run
Apply Git changes without a personsyncPolicy: automated: {}
Delete what Git no longer hasprune: true
Undo hand changes to the clusterselfHeal: true
Check Git now, not on the timerargocd app get bookshop --refresh
Sync by handargocd app sync bookshop
Sync by hand, and pruneargocd app sync bookshop --prune
Never prune one resourceargocd.argoproj.io/sync-options: Prune=false, as an annotation
Let something else own a fieldignoreDifferences: in the Application
Make a ConfigMap file from a pagekubectl create configmap NAME --from-file=index.html=FILE --dry-run=client -o yaml
Change an Application properlyEdit its file, then kubectl apply -f argocd/bookshop.yaml
Quick changes, for testing onlyargocd app set bookshop --auto-prune=false, --sync-policy none

Where you are now

The bookshop looks like a shop, and you've seen each sync setting do its job: automated or manual, self-heal, and prune, with prune's leftovers and manual sync's waiting. You've also seen that a change can be synced well before users see it. The bookshop is still plain YAML, with the page pasted into a ConfigMap by hand. Lesson 5 turns it into a Helm chart: templates and values, so the shop's name, version and copies are settings, not edits.

Check yourself

  1. Argo CD synced the new page in 2 seconds. When did users see it?

  2. With prune on, a Service's file is deleted from Git. What happens?

  3. With prune off, the same thing happens. What does Argo CD do?

  4. With manual sync, a price change is merged. What happens?

  5. After argocd app set, why didn't the Application match argocd/bookshop.yaml?

  6. An autoscaler and Argo CD keep changing replicas back and forth. What's the fix?

Key terms

  • Automated sync: Argo CD applying changes from Git without waiting for a person.
  • Manual sync: Argo CD noticing changes, and waiting for someone to run a sync.
  • Prune: deleting from the cluster what Git no longer has.
  • Self-heal: undoing changes made to the cluster outside Git.
  • Refresh: checking Git now, instead of on the next timed check.
  • Leftover: a resource still in the cluster after Git stopped describing it, because prune was off.
  • ignoreDifferences: an Application setting that tells Argo CD to ignore certain fields, owned by something else.
  • App-of-apps: an Application whose folder holds other Applications, so they're managed from Git too.

Go deeper