Module 5: Gitops With Argocd

5. Anatomy of an ArgoCD `Application`

Description

You have both pieces — Gitea (lesson 3) and ArgoCD (lesson 4) — running, but still unaware of each other: ArgoCD doesn't know andes-cargo-k8s exists, and Gitea has no idea something called ArgoCD is running on the same cluster. The one object still missing is an Application — ArgoCD's central resource, the same one cicd-and-gitops-on-aws-guide M7.3 showed you verified against official documentation but never applied. This lesson dissects that object field by field, with the real YAML you're going to apply in lesson 6 — and closes with literal evidence, already executed on this very lab, of what happens when selfHeal is active.

Connection to the module

This lesson is the hinge between installing the pieces (lessons 3-4) and connecting them (lesson 6). Without understanding what each Application field means, applying lesson 6's manifest would be copying YAML with no judgment — with this lesson, every line of that manifest has a reason you can explain without reading it again.


The complete resource, the same one you're going to apply in lesson 6

# application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: andes-cargo-status-api
  namespace: argocd
spec:
  project: default
  source:
    repoURL: http://gitea-http.gitea.svc.cluster.local:3000/andes-cargo/andes-cargo-k8s.git
    targetRevision: main
    path: .
  destination:
    server: https://kubernetes.default.svc
    namespace: andes-cargo
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=false

This isn't a generic example — it's, literally, the file you're going to apply in lesson 6, with the exact repoURL of the repository you created in lesson 3. It's worth reading it with the same judgment you'd read a Terraform resource or a Deployment with: each top-level block answers a different question.


metadata: where the Application object itself lives

metadata:
  name: andes-cargo-status-api
  namespace: argocd

Here's the first detail that surprises anyone seeing this for the first time: the Application lives in the argocd namespace, not in andes-cargo. An Application is a control object — it belongs to ArgoCD, it's the instruction ArgoCD follows — distinct from the resources that Application manages (the Deployment, the Service, etc., which are going to live in andes-cargo, as destination.namespace declares below). It's the same separation you already know between a HorizontalPodAutoscaler (lives in the namespace of the workload it scales) and a ClusterRole (doesn't live in any namespace, because it's a cluster-scoped resource) — every Kubernetes object lives wherever makes sense for who manages it, not necessarily alongside what it describes.


spec.source: where the desired state comes from

source:
  repoURL: http://gitea-http.gitea.svc.cluster.local:3000/andes-cargo/andes-cargo-k8s.git
  targetRevision: main
  path: .
  • repoURL — Gitea's internal DNS name (lesson 3), not localhost. It's the exact fix lesson 3 already foreshadowed in its "Common mistakes": ArgoCD runs inside the cluster, so it needs the address that resolves from there, not the one that resolves from your machine.
  • targetRevision — the branch, tag, or commit ArgoCD follows. main means "whatever's latest on that branch, always" — every time ArgoCD polls the repository (lesson 2), it asks again "what's on main right now?", not "what was on main when I applied this Application?".
  • path — the folder, inside the repository, that holds the manifests. . means "the root itself" — the nine YAML files you pushed in lesson 3 are all there, with no subfolders. A larger repository, with several applications, would normally use separate subfolders (apps/andes-cargo-status-api/, apps/some-other-service/) and one Application per folder, each pointing at its own path.

spec.destination: where it gets applied

destination:
  server: https://kubernetes.default.svc
  namespace: andes-cargo
  • server — the Kubernetes API ArgoCD is going to apply the manifests against. https://kubernetes.default.svc is a special address: it means "the same cluster where ArgoCD is running," resolved by Kubernetes' internal DNS without you having to write any IP or any extra kubeconfig. ArgoCD can also manage clusters external to its own (a real pattern called hub-and-spoke, where a single central ArgoCD manages several clusters) — outside this guide's scope, but worth knowing the field exists for that.
  • namespaceandes-cargo, the same namespace you declared in namespace.yaml since Module 1. Any repository manifest that does not declare its own explicit metadata.namespace inherits this value; the ones that do declare it explicitly (all nine in this repository do, all with namespace: andes-cargo) use it as-is, with no dependency on this field.

spec.syncPolicy: manual versus automated sync

This is the part that most changes ArgoCD's day-to-day behavior, and the one this lesson explains in most detail because it isn't obvious at a glance.

syncPolicy:
  automated:
    prune: true
    selfHeal: true
  syncOptions:
    - CreateNamespace=false

If the automated block didn't exist (syncPolicy: {}, or no syncPolicy at all), the Application would still exist and keep comparing — lesson 2's loop always keeps running — but ArgoCD would only report the difference (Sync Status: OutOfSync) without applying anything on its own. Someone would have to run argocd app sync andes-cargo-status-api (or click "Sync" from the UI) every time they wanted the change applied — manual sync, useful when a team wants explicit human approval before every change, even one coming from Git.

With automated present, as in this application.yaml, ArgoCD applies the change as soon as it detects it, with no command needed — automated sync, the model the rest of this module uses.

Inside automated, two boolean fields that do different things:

  • prune: true — if a manifest disappears from the repository (someone deletes hpa.yaml and does git push), ArgoCD deletes the corresponding resource from the cluster too. Without prune: true, ArgoCD only applies what still exists in Git, but never deletes what's no longer there — an "orphaned" resource would keep running indefinitely.
  • selfHeal: true — if someone changes something directly on the cluster (kubectl scale, kubectl edit, anything that doesn't go through Git), ArgoCD reverts that change so it matches what Git declares again. It's the more aggressive of the two properties, and the one the next section confirms with real evidence.

syncOptions: [CreateNamespace=false] is an explicit decision for this lab: the andes-cargo namespace already exists (namespace.yaml, applied since Module 1) and keeps living inside the same repository ArgoCD syncs — ArgoCD doesn't need to create it on its own before syncing everything else.


Real evidence: selfHeal in action, on this very lab

Everything that follows actually ran, against the already-synced andes-cargo-status-api Application (the same YAML above, applied as part of building this module) — lesson 6 walks you step by step to this exact point. Before the test, the Deployment had 5 replicas, per the latest recorded convergence. Now, someone — simulating a rushed engineer, bypassing Git — runs this directly against the cluster:

kubectl scale deployment andes-cargo-status-api -n andes-cargo --replicas=2
kubectl get deployment andes-cargo-status-api -n andes-cargo

What to expect (literal, executed — the manual change does apply, for an instant):

deployment.apps/andes-cargo-status-api scaled

NAME                     READY   UP-TO-DATE   AVAILABLE   AGE
andes-cargo-status-api   2/5     5            2           79m

Kubernetes obeyed the manual command without protest — kubectl scale is a valid operation against any Deployment, ArgoCD has no way to "block" it the moment it happens. What happens next is the part that demonstrates selfHeal:

# waiting, without running anything else...
kubectl get deployment andes-cargo-status-api -n andes-cargo

What to expect (literal, executed — about eight seconds later, with no one fixing anything by hand):

NAME                     READY   UP-TO-DATE   AVAILABLE   AGE
andes-cargo-status-api   5/5     5            5           79m

Back to 5 replicas, on its own. The namespace's own event log confirms the exact sequence:

kubectl get events -n andes-cargo --sort-by=.lastTimestamp
...   Normal    ScalingReplicaSet   deployment/andes-cargo-status-api   Scaled down replica set andes-cargo-status-api-548966dd97 from 5 to 2
...   Normal    ScalingReplicaSet   deployment/andes-cargo-status-api   Scaled up replica set andes-cargo-status-api-548966dd97 from 2 to 5

Two events, in that order: first the manual command (Scaled down ... from 5 to 2), then ArgoCD's correction (Scaled up ... from 2 to 5) — with no kubectl apply involved in the correction. argocd app history confirms it too, recording no new deployment tied to this event — because, from Git's point of view, nothing changed; the Deployment simply went back to matching what it already declared.

                    WHAT selfHeal: true JUST DID

  Git says: replicas = 5          Someone runs:                ArgoCD detects
  (source of truth, unchanged)    kubectl scale --replicas=2   the difference
                                   (the cluster now says 2)     and fixes it, alone
        │                                │                          │
        └────────────── continuous comparison (lesson 2) ───────────┘
                                          │
                                          ▼
                              The cluster says 5 again,
                              with no one running
                              kubectl apply or git push

Common mistakes

Assuming syncPolicy.automated means "ArgoCD never lets me touch the cluster by hand" (expectation, corrected by this lesson's own evidence). What happens: someone thinks kubectl scale directly against a resource ArgoCD manages is going to fail or be blocked. How to spot it: this lesson's evidence contradicts it — the manual command did apply, for about eight seconds. How to fix it: selfHeal doesn't prevent the manual change the moment it happens — Kubernetes still accepts any valid command against any resource. What it does is revert it on the next comparison, which in this lab took about eight seconds. A very brief manual change (faster than ArgoCD's comparison cycle) would technically take effect for an instant — but it doesn't last.

Confusing prune: true with "deletes everything it doesn't recognize" (scope, matters for shared namespaces). What happens: someone worries prune: true is going to delete resources from other teams sharing the same namespace. How to spot it: if you're not clear on exactly which resources fall within the scope of "what Git declares" for this Application. How to fix it: prune only acts on resources ArgoCD already manages — the ones created as part of an earlier sync of this same Application, identified by an internal label ArgoCD adds. A resource another team created by hand, never going through this Application, never falls within prune's scope, no matter what namespace it lives in.

Thinking Application lives in the same namespace it manages, by analogy with other resources (conceptual, already explained above but easy to forget). What happens: someone looks for the Application with kubectl get application -n andes-cargo and finds nothing. How to spot it: the command returns an empty list or an error. How to fix it: Application lives in argocd (ArgoCD's namespace), not in the namespace it declares as destinationkubectl get application -n argocd is the correct command.


Exercises

Exercise 1 — Rewrite syncPolicy for manual sync, unassisted. Starting from this lesson's complete YAML, write the version of syncPolicy that would leave ArgoCD reporting differences without applying them automatically.

See solution
syncPolicy:
  syncOptions:
    - CreateNamespace=false

It's enough to remove the entire automated block (prune/selfHeal) — without it, ArgoCD keeps comparing (the loop never stops) but leaves Sync Status at OutOfSync when it finds a difference, instead of fixing it on its own. Someone would have to explicitly run argocd app sync andes-cargo-status-api to apply the change.

Exercise 2 — Explain why Application lives in argocd, in your own words. Without copying this lesson's text, explain to a colleague why the Application that manages andes-cargo resources doesn't itself live in the andes-cargo namespace.

See solution

A reasonable explanation: "A Kubernetes object's namespace normally reflects who manages it, not necessarily what it describes. The Application is a control object that belongs to ArgoCD — it lives alongside the rest of ArgoCD's objects (argocd), even though what it describes (a Deployment, a Service) lives in a completely different namespace (andes-cargo). It's similar to how a remote control doesn't live inside the TV it controls."

Exercise 3 — Predict the outcome if prune were false in Module 4, lesson 8's scenario. Recall Module 4's checklist: nine manifests, including two NetworkPolicy. If prune: false (instead of true) and someone deleted networkpolicy-default-deny.yaml from the repository, with git push, what would you expect to see on the cluster?

See solution

The default-deny-ingress NetworkPolicy would keep existing on the cluster, unchanged — with prune: false, ArgoCD never deletes a resource that no longer appears in Git, it only applies what's still there. Sync Status would probably show a partial difference (the file disappeared from Git, but the resource is still on the cluster), with ArgoCD never resolving it on its own — exactly why this lab uses prune: true: a Git repository that's genuinely the single source of truth needs deleting a file to have the same effect as deleting the resource.


Summary and next step

This lesson dissected the complete Application you're going to apply in lesson 6: source (where the desired state comes from — Gitea's repository, main branch, repository root), destination (where it gets applied — the cluster itself, andes-cargo namespace), and syncPolicy (how it syncs — automated, with prune and selfHeal active). You confirmed, with real, not promised, evidence, that selfHeal: true fixes a manual change in seconds, with no one running any corrective command — the GitOps property cicd-and-gitops-on-aws-guide's drift.yml never actively implemented.

Before moving on you should be able to: explain every top-level field of an Application unassisted; distinguish manual sync from automated sync with a YAML example; and predict what prune: true does versus prune: false when a file gets deleted from the repository.

Next lesson: hands-on, syncing andes-cargo-status-api from Git. There you apply, yourself, exactly this Application — and watch, step by step, the whole cluster's first real convergence.

Resources

  1. Argo CD — Application Specification — complete official reference for every spec field, including ones this lesson didn't cover because they're outside this lab's scope.
  2. Argo CD — Sync Options — documentation for CreateNamespace and the rest of syncOptions' options.
  3. Argo CD — Automated Sync Policy — official documentation for prune and selfHeal, the source for this lesson's explanation.
  4. cicd-and-gitops-on-aws-guide (NIEVA), Module 7, lesson 3 — the original Application YAML, shown without running, that this lesson finally applies for real.