Containerization Lesson 60 of 113

GitOps at Scale with Argo CD: App-of-Apps, ApplicationSets & Progressive Delivery

In a nutshell

GitOps means one simple thing: Git is the source of truth for what runs in your cluster, and a controller continuously makes the cluster match Git. Think of a thermostat. You set a target temperature (that is your desired state, written down in Git); the thermostat reads the room and runs the heater or the AC until the room matches the number. You never hold a lighter under the sensor to “fix” the temperature — you change the setpoint. GitOps is the same discipline for infrastructure: you never kubectl apply from your laptop to fix production; you change Git, and Argo CD — the thermostat — closes the gap.

App-of-apps is how you turn on a whole platform with a single switch. Instead of applying fifty things by hand, you apply one root Application that points at a folder full of child Applications. Argo CD reads the folder and brings every child up for you — like a master power switch on a rack that boots every server in order. One kubectl apply bootstraps the entire cluster.

Progressive delivery is a dimmer switch instead of an on/off toggle. A normal deploy flips 100% of traffic to the new version at once and hopes for the best. Progressive delivery — canary and blue-green rollouts, driven by Argo Rollouts — turns the dial up slowly (10%, then 50%, then 100%), watches your error rate and latency at each step, and snaps back to the old version the instant something looks wrong. Automatic, in seconds, before most users ever notice.

Put those three ideas together and you get a delivery system where every change is written down, reviewable, reversible, and rebuildable from Git. This lesson takes you from that mental model all the way to running it across many teams and several clusters.

Level: Intermediate → Advanced · Time: ~30 min · Builds on the Kubernetes fundamentals (Deployments, Services, namespaces) and basic Git/PR workflow.

GitOps with Argo CD: Git holds desired state, the reconcile loop syncs the cluster, a root Application fans out to children, and a Rollout gates risky changes with a canary and analysis

The diagram is the whole lesson in one picture. The desired state lives in Git (left): plain Application manifests plus a Rollout. Argo CD’s reconcile loop diffs Git against the live cluster and syncs the difference. One root Application fans out into child Applications (app-of-apps), which land as Synced and Healthy workloads in the cluster. For the changes you do not fully trust, a Rollout shifts a small canary slice, lets an AnalysisRun judge the metrics, and either promotes on green or auto-rolls-back on red. Each numbered badge marks a place teams trip the first time; the legend gives the symptom and the fix.

Argo CD turns a Git repository into the single source of truth for what runs in your clusters. That promise is easy to demo with one app and one cluster, and surprisingly hard to keep once you have dozens of teams, several environments, and a handful of regional clusters. This article walks through the patterns that survive that growth: a repository layout that scales, the app-of-apps bootstrap, ApplicationSets for fan-out, ordered syncs, secret handling, and progressive delivery with Argo Rollouts.

The Application object, field by field

Everything in Argo CD is built from one custom resource: the Application. Argo CD installs it as a CRD, and every Application object lives in the argocd namespace. Before the patterns, it pays to understand the one object they are all made of, because a beginner who can read an Application can read the whole system.

An Application answers four questions:

Here is a single, annotated Application — one app, not the whole platform — so each block has a face:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: checkout
  namespace: argocd                 # Application OBJECTS always live here
spec:
  project: tenants                  # WHO: an AppProject that whitelists repos + destinations
  source:                           # WHERE the desired state is
    repoURL: https://github.com/acme/apps-gitops.git
    targetRevision: main            # a branch, a tag, or a pinned commit SHA
    path: checkout/overlays/prod-eastus   # a Kustomize / Helm / plain-manifest directory
  destination:                      # WHERE it should run
    name: prod-eastus               # a registered cluster by name (or server: <api-url>)
    namespace: checkout             # the app's OWN namespace (not argocd)
  syncPolicy:                       # HOW Argo CD keeps live == Git
    automated:
      prune: true                   # delete resources that were removed from Git
      selfHeal: true                # revert any manual change back to Git
    syncOptions:
      - CreateNamespace=true        # make the target namespace if it is missing

Note the two namespaces that trip everyone up: the Application object lives in argocd (metadata.namespace), but the workload it deploys goes wherever spec.destination.namespace says. They are almost never the same.

Field What it answers Typical value
spec.source.repoURL Which repo holds the manifests A Git HTTPS/SSH URL
spec.source.targetRevision Which version of it main, a tag, or a commit SHA
spec.source.path Which directory in the repo checkout/overlays/prod
spec.source.chart (Helm alt) which chart argo-cd from a chart repo
spec.destination.server / .name Which cluster API URL, or a registered name
spec.destination.namespace Which namespace The app’s own namespace
spec.syncPolicy.automated Auto-sync on/off + prune/selfHeal Present = automated
spec.project Which AppProject bounds it default or a scoped project

The other detail that matters early is that Argo CD reports two independent statuses for every app, and beginners routinely confuse them. Sync status answers “does the live cluster match Git?”; health status answers “are the workloads actually working?” An app can be Synced (the manifest applied cleanly) and Degraded at the same time (the Pods it created are crash-looping). Learn to read both:

Sync status — does live match Git?

Value Meaning
Synced The live cluster matches the desired manifests in Git
OutOfSync Git and live differ — a sync is pending (manual) or in progress
Unknown Argo CD cannot compute the diff (repo unreachable, bad path)

Health status — are the workloads working?

Value Meaning
Healthy Resources are up and passing their per-kind health check
Progressing Rolling out — a Deployment mid-update waiting for Ready pods
Degraded Failed its health check — crash-loop, failed rollout, unschedulable
Suspended Paused on purpose — a Rollout at a canary pause, a suspended CronJob
Missing Declared in Git but not present in the cluster yet
Unknown Health could not be determined

When something is wrong, the first question is always: which of the two is red? A red sync status is a Git/apply problem (bad path, RBAC, a project that forbids the repo). A red health status is a workload problem (image pull, crash-loop, failing probe) — Git is fine, the app itself is broken.

1. GitOps principles and a repository layout that scales

GitOps rests on a few non-negotiable rules. The desired state lives in Git. A controller continuously reconciles actual state toward that desired state. Changes happen through pull requests, not kubectl apply from a laptop. Drift is detected and either reported or corrected automatically.

The hardest design decision is repository structure. Two anti-patterns dominate: one giant repo where every team blocks on every other team’s reviews, and per-environment branches where promotion becomes a merge nightmare and main no longer reflects production. Use directories, not branches, for environments. Promotion is then a small, reviewable diff that copies a tested image tag from one path to another.

A layout that has held up well across many teams:

platform-gitops/                 # cluster-scoped, owned by platform team
  bootstrap/
    root-app.yaml                # the one app you apply by hand
  addons/                        # ingress, cert-manager, monitoring, ESO
    cert-manager/
    ingress-nginx/
  appsets/                       # ApplicationSets that fan apps out
    tenants.yaml
  clusters/
    prod-eastus/values.yaml      # per-cluster config (region, sizing)
    prod-westeu/values.yaml
    staging/values.yaml

apps-gitops/                     # namespace-scoped, owned by app teams
  checkout/
    base/                        # Kustomize base or Helm chart
    overlays/
      staging/
      prod-eastus/
      prod-westeu/

Keep platform concerns and application concerns in separate repositories with separate CODEOWNERS. The platform team should not gate every app deploy, and app teams should not be able to edit cluster-wide RBAC.

2. Bootstrap with the app-of-apps pattern

The app-of-apps pattern means you apply exactly one Application by hand. That root Application points at a directory of child Applications, and Argo CD reconciles them recursively. After bootstrap, everything (including Argo CD’s own configuration) is managed by Git.

Install Argo CD first, then apply the root app:

kubectl create namespace argocd
kubectl apply -n argocd \
  -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

# Wait for the core controllers to be ready
kubectl rollout status deploy/argocd-server -n argocd
kubectl rollout status statefulset/argocd-application-controller -n argocd

The root Application is the only manifest you kubectl apply directly. It watches the bootstrap/ directory and creates everything else:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://github.com/acme/platform-gitops.git
    targetRevision: main
    path: bootstrap
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

The resources-finalizer.argocd.argoproj.io finalizer matters: without it, deleting the root Application orphans its children instead of cascading the cleanup. With it, argocd app delete root tears the whole tree down in dependency order.

Mentally, the tree looks like this: the root Application’s source.path (bootstrap/) is a folder of more Application manifests — addons, appsets, monitoring. Syncing root applies those child Application objects into the argocd namespace; each child then reconciles its own source into its own destination. It is Applications all the way down, and one hand-applied root is the only imperative step in the entire platform.

3. Fan apps out with ApplicationSets

App-of-apps gets clumsy when you need the same app on ten clusters or one app per team. The ApplicationSet controller (bundled with Argo CD) generates Applications from a template plus a generator. The most useful generators are git (directories or files in a repo), cluster (registered Argo CD clusters by label), and matrix (the cross-product of two generators).

This ApplicationSet deploys every app overlay onto every production cluster, combining a git directory generator with a cluster generator:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: tenant-apps
  namespace: argocd
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]
  generators:
    - matrix:
        generators:
          - git:
              repoURL: https://github.com/acme/apps-gitops.git
              revision: main
              directories:
                - path: "*/overlays/prod-*"
          - clusters:
              selector:
                matchLabels:
                  env: prod
  template:
    metadata:
      name: "{{.path.basename}}-{{.name}}"
    spec:
      project: tenants
      source:
        repoURL: https://github.com/acme/apps-gitops.git
        targetRevision: main
        path: "{{.path.path}}"
      destination:
        server: "{{.server}}"
        namespace: "{{.path[1]}}"
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true

Register clusters so the clusters generator can find them, and label them so your selector works:

# Add a remote cluster from a kubeconfig context
argocd cluster add prod-eastus-context --name prod-eastus

# Label the generated cluster secret so ApplicationSets can target it
kubectl label secret -n argocd \
  -l argocd.argoproj.io/secret-type=cluster \
  env=prod region=eastus --overwrite

Set the ApplicationSet’s syncPolicy.applicationsSync deliberately. By default, deleting a generator’s source element deletes the generated Application. In production, use a preserve policy until you trust the generators, so a bad selector edit cannot wipe live workloads. Roll out generator changes behind a PR review like any other change.

4. Order dependent resources with sync waves and hooks

Argo CD applies resources in waves. A CRD must exist before the custom resource that uses it; a database migration must finish before the new app version starts. Annotate resources with argocd.argoproj.io/sync-wave (an integer, default 0); lower waves apply first, and Argo CD waits for each wave to become healthy before starting the next.

# CRDs and namespaces go early
metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "-5"
---
# The app that depends on them goes later
metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "0"

Resource hooks run logic at points in the sync. A PreSync Job is the right place for schema migrations:

apiVersion: batch/v1
kind: Job
metadata:
  name: db-migrate
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
  backoffLimit: 2
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: ghcr.io/acme/checkout:1.8.0
          command: ["/app/migrate", "up"]

PreSync runs before the main sync, PostSync after all resources are healthy, and SyncFail only when a sync fails. The hook-delete-policy of HookSucceeded cleans up the Job once it passes so it does not accumulate. If a PreSync migration fails, the sync stops and the new version never rolls out, which is exactly what you want.

5. Manage secrets in GitOps

Plaintext secrets cannot live in Git. Two mature approaches solve this without breaking the “Git is the source of truth” model.

Sealed Secrets encrypts a Secret with a controller-held key. The encrypted SealedSecret is safe to commit; only the in-cluster controller can decrypt it.

# Encrypt locally, commit the output
kubectl create secret generic api-creds \
  --from-literal=token=s3cr3t --dry-run=client -o yaml \
  | kubeseal --controller-namespace kube-system --format yaml \
  > sealed-api-creds.yaml

External Secrets Operator (ESO) keeps secret values in a real secret manager (Azure Key Vault, AWS Secrets Manager, GCP Secret Manager, Vault) and syncs them into Kubernetes Secrets. Only a reference lives in Git:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: api-creds
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: azure-kv
    kind: SecretStore
  target:
    name: api-creds
  data:
    - secretKey: token
      remoteRef:
        key: checkout-api-token
Concern Sealed Secrets External Secrets Operator
Where values live Encrypted, in Git External secret manager
Rotation Re-seal and commit Change in the manager; auto-syncs
Audit trail Git history Manager’s audit log
Extra dependency One controller Operator plus a cloud secret store

For multi-cluster platforms, ESO usually wins: rotating a credential is a change in one secret store, not a commit fanned across every cluster overlay. Reserve Sealed Secrets for bootstrap-time secrets that must exist before ESO itself is running.

6. Progressive delivery with Argo Rollouts

A plain Kubernetes Deployment only does rolling updates. Argo Rollouts replaces the Deployment with a Rollout resource that understands canary and blue-green strategies, and gates promotion on metric AnalysisRuns. It integrates with Argo CD: the Rollout shows up as just another resource Argo CD reconciles.

A canary that shifts traffic in steps and runs analysis between them:

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: checkout
spec:
  replicas: 6
  selector:
    matchLabels:
      app: checkout
  template:
    metadata:
      labels:
        app: checkout
    spec:
      containers:
        - name: checkout
          image: ghcr.io/acme/checkout:1.8.0
  strategy:
    canary:
      steps:
        - setWeight: 10
        - pause: { duration: 5m }
        - analysis:
            templates:
              - templateName: success-rate
        - setWeight: 50
        - pause: { duration: 10m }
        - setWeight: 100

The AnalysisTemplate queries Prometheus and fails the rollout if the success rate drops below threshold, which triggers an automatic rollback to the stable ReplicaSet:

apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: success-rate
spec:
  metrics:
    - name: success-rate
      interval: 1m
      count: 5
      successCondition: result[0] >= 0.99
      failureLimit: 1
      provider:
        prometheus:
          address: http://prometheus.monitoring:9090
          query: |
            sum(rate(http_requests_total{app="checkout",code!~"5.."}[2m]))
            /
            sum(rate(http_requests_total{app="checkout"}[2m]))

Blue-green is the other strategy: a blueGreen block with activeService and previewService brings the new version up in full alongside the old one, lets you test it on the preview service, then flips the active service on promotion. Drive promotions with the plugin rather than editing live objects:

kubectl argo rollouts get rollout checkout --watch
kubectl argo rollouts promote checkout          # advance to the next step
kubectl argo rollouts abort checkout            # roll back to stable

Canary vs blue-green — which to reach for. They solve the same problem (ship a new version without a big-bang cutover) with opposite trade-offs:

Dimension Canary Blue-green
Traffic shift Gradual (10% → 50% → 100%) Instant flip of the active Service
Extra capacity Only the canary slice of pods 2× — the full new stack runs alongside old
Real users during test A small, growing slice None until the flip (preview Service only)
Rollback Shift weight back / abort Re-point the active Service selector
Best for Stateless APIs with good metrics Changes you must validate whole before any real traffic

One gotcha worth internalising early: with no trafficRouting configured, setWeight: 10 does not send exactly 10% of requests to the canary. The controller simply scales the canary ReplicaSet to roughly 10% of the pods and lets the Service load-balance across all of them — pod ratio is a proxy for weight, not a real percentage. True per-request weighting needs a traffic router (Istio, NGINX, ALB, or the Gateway API), which the deeper Rollouts lessons cover.

7. Drift detection, self-healing, and safe pruning

With selfHeal: true, Argo CD reverts any manual change that diverges from Git within its reconcile interval. That is the behavior you want in production: a kubectl edit hotfix gets undone, forcing the fix through a PR. With prune: true, resources removed from Git are deleted from the cluster.

Pruning is the dangerous half. Guard it with two mechanisms. Mark resources you never want auto-deleted (a PersistentVolumeClaim, a namespace) with the prune protection annotation. And require confirmation for large deletions so a bad refactor cannot quietly remove a hundred objects:

# Never let Argo CD prune this resource
metadata:
  annotations:
    argocd.argoproj.io/sync-options: Prune=false
# Require manual confirmation for destructive prunes (per-app)
spec:
  syncPolicy:
    syncOptions:
      - PruneLast=true          # prune after other resources sync

Treat selfHeal and prune as production discipline, not just features. The combination guarantees that the cluster matches Git and that the only way to change the cluster is to change Git. That is the entire point of GitOps; turning them off quietly reintroduces snowflake drift.

8. Disaster recovery: rebuild a cluster from Git

If GitOps is real, a destroyed cluster is recoverable by pointing Argo CD at the same repo. The recovery runbook is short because the heavy lifting is declarative.

# 1. Provision a fresh cluster (Terraform/Bicep), get a kubeconfig.
# 2. Install Argo CD.
kubectl create namespace argocd
kubectl apply -n argocd \
  -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

# 3. Re-add cluster credentials and labels (step 3 above).
# 4. Apply the single root Application; everything else follows.
kubectl apply -f bootstrap/root-app.yaml

The one thing Git cannot rebuild is decryption material. Back up the Sealed Secrets controller key and any ESO authentication out of band, because without them the committed SealedSecrets are inert and ESO cannot reach the secret store:

# Back up the Sealed Secrets private key (store in a vault, NOT Git)
kubectl get secret -n kube-system \
  -l sealedsecrets.bitnami.com/sealed-secrets-key \
  -o yaml > sealed-secrets-key-backup.yaml

Enterprise scenario

A payments platform ran ~40 services across four prod clusters via one ApplicationSet using a git directory generator on */overlays/prod-*. A team renamed an overlay directory in a routine PR. The git generator stopped emitting the old element, the ApplicationSet controller deleted the corresponding Application, and because every app had prune: true plus selfHeal: true, Argo CD cascaded deletes across all four clusters within one reconcile loop. A live payment service went down before anyone connected the directory rename to the outage.

Two root causes: the ApplicationSet defaulted to deleting Applications when a generator element disappears, and nothing distinguished “intentional removal” from “rename.” The fix was to stop treating generator output as authoritative for deletion. They set the preserve policy so a vanished element orphans rather than deletes the Application, requiring an explicit, reviewed delete:

spec:
  syncPolicy:
    preserveResourcesOnDeletion: true   # vanished generator element != delete
  # plus a guard on the apps the set generates
  template:
    metadata:
      annotations:
        argocd.argoproj.io/sync-options: Delete=confirm

They also added a CI check that diffs the rendered Application list (argocd appset generate against the PR branch) and fails the build if the count drops, so any deletion shows up in review as an explicit number. The deeper lesson: in a fan-out model, a one-line path edit has cluster-wide blast radius. Generators are convenient, but their output must never be the only thing standing between a typo and a production deletion.

Going deeper

You now have the core patterns. This section is for the reader who has to operate them at scale, and it maps directly onto the numbered badges in the diagram.

ApplicationSet generators, in full

The git and cluster generators are the two you meet first, but there are more, and each unlocks a different fan-out pattern:

Generator Emits one Application per… Use it for
list Hard-coded element in a literal list A small, explicit fixed set
cluster Registered cluster matching a label selector The same app on every prod cluster
git (directories) Directory matched by a glob in a repo One app per overlay folder
git (files) Config file matched by a glob Per-tenant config files
matrix Cross-product of two child generators “Every app × every cluster”
merge Merged element across generators (override by key) Base list + per-cluster overrides
scmProvider Repository in a GitHub/GitLab org Onboard every repo automatically
pullRequest Open PR against a repo Ephemeral preview environments

For large fleets, add a progressive sync strategy (strategy: type: RollingSync) so the set rolls its generated Applications out cluster-group by cluster-group — canary at the fleet level — instead of updating all of them at once. That turns “one PR touches forty clusters” from a cliff into a staged wave.

Sync waves vs resource hooks

They look similar but do different jobs. A sync wave orders resources within one sync: Argo CD applies the lowest wave first and waits for it to report Healthy before the next. A resource hook injects a step around the sync at a named phase — PreSync, Sync, PostSync, or SyncFail. Reach for a wave to say “CRDs before the custom resources that use them”; reach for a hook to say “run a migration Job, and if it fails, abort the whole sync.” Hooks honour their own hook-delete-policy (HookSucceeded, HookFailed, BeforeHookCreation) so they clean up instead of piling up.

The reconcile loop and drift detection

Badge 2 in the diagram is the heart of Argo CD. The application-controller runs a loop: fetch the desired manifests from Git, render them (Helm/Kustomize), diff them against the live objects, and compute a per-app sync status. The diff is not a naive text compare — it normalises defaults and ignores fields Argo CD is told to ignore, so it does not thrash on server-populated values. The loop re-runs on a timer (timeout.reconciliation, default 180s) and immediately on a Git webhook. When automated sync is on and the app is OutOfSync, it syncs; when selfHeal is on, live drift (a hand-edit) is treated the same way and reverted on the next pass. At scale you shard the controller across replicas so each owns a subset of clusters — the knob that keeps reconcile latency flat as you add clusters.

Multi-cluster, hub-and-spoke

The common topology is one Argo CD hub managing many spoke clusters. Each spoke is registered as a Secret of type cluster in the argocd namespace, carrying its API URL and credentials; the cluster generator selects them by label. The hub needs network reach and credentials to every spoke, and you bound each team with an AppProject that whitelists the repos, clusters, and namespaces they may target — so a tenant cannot deploy into another team’s cluster even by editing their own manifest. A production build of exactly this hub-spoke pattern is covered in App-of-Apps multi-cluster GitOps.

Rollouts traffic management and metric-driven auto-rollback

Real canaries route real traffic. Argo Rollouts integrates with a traffic provider — Istio, NGINX, AWS ALB, SMI, or the Gateway API plugin — to weight requests precisely instead of by pod ratio. The gate itself is an AnalysisRun, and its provider is pluggable:

Analysis provider Reads from
prometheus PromQL query result
datadog A Datadog metric query
cloudWatch A CloudWatch metric
newRelic / wavefront Their respective query APIs
web Any HTTP endpoint returning JSON
job A Kubernetes Job’s exit code (0 = pass)

Analysis can run inline (a step in the canary list, blocking promotion) or in the background (started at a step and evaluated continuously for the rest of the rollout). Either way, when failureLimit is exceeded the rollout aborts automatically to the still-running stable ReplicaSet — no human in the loop, rollback measured in seconds. The metric-driven canary and blue-green analysis gates are explored in depth in Progressive delivery with Argo Rollouts and canary metrics and Blue-green with preview and analysis gates.

Secrets: a third option, and the hard constraint

Beyond Sealed Secrets and ESO, teams also use SOPS + age (encrypt the file, decrypt at sync time via the argocd-vault-plugin or a KSOPS plugin). All three keep ciphertext or references — never plaintext — in Git. The hard constraint underneath every approach: the decryption key or the manager credential can never live in Git, because Git is exactly what an attacker who compromises the repo already holds. Back that material up out of band (badge relates to the DR runbook above).

The CI → CD boundary: Argo CD is CD, not CI

The single most common conceptual error is expecting Argo CD to build your code. It does not. CI (GitHub Actions, GitLab CI, Jenkins) compiles, tests, scans, builds the image, and pushes it to a registry — then writes the new image tag into the GitOps repo (by committing, or via Argo CD Image Updater writing the tag back). CD (Argo CD) only reconciles what is in Git. The handoff is a Git commit: CI produces artifacts and a tag; CD consumes the tag. Keep that line bright and your pipeline stays debuggable — a broken build is a CI problem, a stuck deploy is a CD problem, and you always know which console to open.

Common beginner mistakes

Practice challenges

Work these top to bottom; each builds on the last. Try before opening the solution.

1 — Beginner: read the two statuses. argocd app get checkout shows Sync Status: Synced and Health Status: Degraded. Is the problem in Git or in the workload, and where do you look next?

<details> <summary>Solution</summary>

The workload. Synced means the live manifests match Git, so the apply was fine — Git is not the issue. Degraded means the resources failed their health check, so look at the Pods: kubectl get pods -n checkout, then kubectl describe/logs on the failing one. Typical causes are an image that will not pull, a crash-loop, or a failing readiness probe. Why: sync status and health status are independent — a red health with a green sync always points at the app, not the manifest.

</details>

2 — Beginner: a single Application. Write an Application named guestbook that deploys the path guestbook/overlays/staging from https://github.com/acme/apps-gitops.git (branch main) into namespace guestbook on the in-cluster server, with automated sync, self-heal, and auto-created namespace.

<details> <summary>Solution</summary>

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: guestbook
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/acme/apps-gitops.git
    targetRevision: main
    path: guestbook/overlays/staging
  destination:
    server: https://kubernetes.default.svc
    namespace: guestbook
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

Why: source says where the desired state is, destination says where it runs, syncPolicy.automated turns on continuous reconcile with prune and self-heal, and CreateNamespace=true saves you a manual kubectl create namespace.

</details>

3 — Intermediate: a root app-of-apps. Author a root Application whose source.path is bootstrap/, holding child Applications, so that a single kubectl apply brings up the whole platform. Make deletion cascade cleanly. What does applying it actually do?

<details> <summary>Solution</summary>

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://github.com/acme/platform-gitops.git
    targetRevision: main
    path: bootstrap
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

kubectl apply -f root-app.yaml creates one Application. Argo CD syncs it, which applies every child Application manifest under bootstrap/ into the argocd namespace; each child then reconciles its own source into its own destination. Why the finalizer: resources-finalizer.argocd.argoproj.io makes argocd app delete root cascade to the children instead of orphaning them.

</details>

4 — Intermediate: make prune safe on stateful data. A namespace runs Postgres backed by a PersistentVolumeClaim. Auto-sync with prune: true is on platform-wide. Stop a manifest removal from ever deleting the PVC, and require confirmation for large deletes.

<details> <summary>Solution</summary>

# On the PVC: never auto-prune it
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-data
  annotations:
    argocd.argoproj.io/sync-options: Prune=false
---
# On the Application: gate destructive deletes behind confirmation
spec:
  syncPolicy:
    syncOptions:
      - PruneLast=true
      - RespectIgnoreDifferences=true

Why: Prune=false exempts the PVC from deletion even if it vanishes from Git, so the data survives a bad refactor. PruneLast=true prunes only after everything else syncs, shrinking the window where a half-applied change deletes something it should not. In the UI you can also require Delete=confirm for a manual sign-off on destructive prunes.

</details>

5 — Advanced: a metric-gated canary Rollout. Convert checkout (6 replicas) to a Rollout that goes 20% → pause → analysis → 50% → 100%, where the analysis fails the rollout if the Prometheus success rate drops below 99%. What happens automatically when the metric fails?

<details> <summary>Solution</summary>

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: checkout
spec:
  replicas: 6
  selector:
    matchLabels:
      app: checkout
  template:
    metadata:
      labels:
        app: checkout
    spec:
      containers:
        - name: checkout
          image: ghcr.io/acme/checkout:1.9.0
  strategy:
    canary:
      steps:
        - setWeight: 20
        - pause: { duration: 5m }
        - analysis:
            templates:
              - templateName: success-rate
        - setWeight: 50
        - pause: { duration: 5m }
        - setWeight: 100

It reuses the success-rate AnalysisTemplate from section 6. What happens on failure: when the success rate stays under 0.99 past failureLimit, the AnalysisRun fails, the Rollout aborts automatically, and traffic snaps back to the stable ReplicaSet — which never stopped running — in seconds. No human, no re-deploy of the old image.

</details>

6 — Advanced: from copy-paste to a generator. An app-of-apps hand-copies one frontend Application onto three prod clusters (prod-eastus, prod-westeu, prod-apac). Replace the three copies with one ApplicationSet using a cluster generator — and make it safe against the rename-outage from the enterprise scenario.

<details> <summary>Solution</summary>

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: frontend
  namespace: argocd
spec:
  goTemplate: true
  syncPolicy:
    preserveResourcesOnDeletion: true   # a vanished cluster != delete the app
  generators:
    - clusters:
        selector:
          matchLabels:
            env: prod
  template:
    metadata:
      name: "frontend-{{.name}}"
    spec:
      project: tenants
      source:
        repoURL: https://github.com/acme/apps-gitops.git
        targetRevision: main
        path: frontend/overlays/prod
      destination:
        server: "{{.server}}"
        namespace: frontend
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true

Why: the cluster generator emits one Application per labeled prod cluster, so adding a fourth cluster is a label, not a copy-paste. preserveResourcesOnDeletion: true means if a cluster drops out of the selector (or someone fat-fingers a label), the generated Application is orphaned for review instead of deleted — the exact guard the payments team added after their outage.

</details>

Verify

After bootstrap or a recovery, confirm the platform actually converged.

# Every Application should report Synced + Healthy
argocd app list -o wide

# Inspect the root app's resource tree and drift
argocd app get root --refresh

# ApplicationSets generated the expected Applications
kubectl get applicationsets -n argocd
kubectl get applications -n argocd

# A canary is progressing as designed
kubectl argo rollouts status checkout

# Secrets actually materialized from references
kubectl get externalsecrets -A
kubectl get sealedsecrets -A

A converged platform shows every Application Synced/Healthy, no OutOfSync resources after a --refresh, Rollouts in a Healthy or Paused (mid-canary) phase, and target Secrets present where ExternalSecrets expect them.

Checklist

Glossary

Pitfalls and next steps

The failures that bite teams are rarely Argo CD bugs. They are process gaps: an aggressive ApplicationSet selector that deletes live apps, prune enabled on a namespace holding a database, or a DR plan that assumes Git holds the secrets it cannot decrypt. Rehearse the destroy-and-rebuild path on a disposable cluster before you need it, and scope ApplicationSet preserve policies until the generators are proven.

From here, harden the platform with Argo CD Projects to restrict which repos, clusters, and namespaces each team can target; add notifications on sync failures and degraded health; and wire image updates through a controller or CI commit so promotions become reviewable diffs rather than manual tag edits. Pair that with Rollouts analysis backed by real SLO queries, and you have a multi-cluster delivery system where every change is auditable, reversible, and reconstructable from Git. To go deeper on the delivery half, continue with progressive delivery and canary metrics; to go deeper on the fleet half, see app-of-apps multi-cluster GitOps.

GitOpsArgo-CDArgo-RolloutsKubernetesCD
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments