Education › DevOps › Stage 3: Run at scale

GitOps with Argo CD

Git as the source of truth, continuous reconciliation, and drift detection.

Intermediate ~30 min read Module 13 of 17

In the pipelines you have built so far, CI holds cluster credentials and pushes changes with kubectl or helm. That works, but the cluster's real state lives only in the cluster, anyone with access can change it unnoticed, and rebuilding it after a disaster means replaying history. GitOps turns the arrow around: Git holds the complete desired state, and an agent inside the cluster continuously pulls it and corrects any difference. This module explains the model and implements it with Argo CD.

After this module you can
  • State the four GitOps principles and contrast pull-based with push-based deployment
  • Install Argo CD and define an Application that syncs a Git path to a cluster namespace
  • Interpret sync status and health status, and configure automated sync, pruning and self-heal
  • Structure repositories and promote a change across environments through pull requests
  • Handle secrets, rollbacks and drift in a GitOps workflow

The GitOps model

GitOps is an operating model, not a product. The OpenGitOps project summarises it in four principles.

  1. Declarative. The desired state of the system is expressed declaratively, as Kubernetes manifests, Helm charts or Kustomize overlays.
  2. Versioned and immutable. That desired state is stored so that history is kept and versions cannot be altered. In practice, Git.
  3. Pulled automatically. Software agents pull the desired state from the source; nothing external pushes into the cluster.
  4. Continuously reconciled. Agents constantly compare actual with desired state and act to converge them.
Push (classic CI/CD)Pull (GitOps)
Who applies changesThe CI runner, from outsideAn agent inside the cluster
Cluster credentialsStored in the CI systemNever leave the cluster
Manual kubectl changesPersist unnoticedDetected as drift and optionally reverted
Audit trailCI logsGit history: who, what, when, reviewed by whom
Disaster recoveryRe-run pipelines in the right orderPoint a new cluster at the repository
OUTSIDE: CI WRITES ONLY TO GIT AND THE REGISTRYIN THE CLUSTERmergetriggerspush imagecommit the new tagwatches, pullsapply, prune, self-healpull imageDevelopermerges a changeCItest, buildRegistryorders:7f3a9c1App reposource codeDeploy repooverlays/stagingArgo CDreconcile loopWorkloadsdesired = actual
Pull-based GitOps: CI only builds images and commits a new tag to the deploy repository. Argo CD, running inside the cluster, pulls that desired state and reconciles the cluster to it, so no credentials for the cluster ever leave it.

The security gain is significant. A CI system that can deploy to production is a high-value target, because compromising it compromises the cluster. With GitOps, CI needs permission only to push to a Git repository and an image registry. The cluster reaches out; nothing reaches in.

You already know the underlying idea. It is the Kubernetes reconciliation loop, extended one level up, so that Git, and no longer whoever last ran kubectl apply, defines what the cluster should be. Argo CD and Flux are the two main implementations, and both are CNCF graduated projects.

Installing Argo CD and defining an Application

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

kubectl -n argocd get pods
kubectl -n argocd port-forward svc/argocd-server 8080:443      # UI on https://localhost:8080

# initial admin password
kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath='{.data.password}' | base64 -d

The central object is the Application, a custom resource that links a source, which is a repository, a revision and a path, to a destination, which is a cluster and a namespace. Because it is itself a Kubernetes manifest, it belongs in Git too.

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

Argo CD detects what the path contains. Plain YAML is applied as it is, a kustomization.yaml is built with Kustomize, and a Chart.yaml is rendered with Helm. For Helm, note that Argo CD runs the equivalent of helm template and applies the output. It does not create Helm releases, so helm list will not show them.

A Helm chart as the source
yaml
source:
  repoURL: https://github.com/acme/shop-deploy.git
  targetRevision: main
  path: charts/orders
  helm:
    valueFiles:
      - values.yaml
      - values-prod.yaml

Sync status, health and the three switches

Argo CD reports two independent things about every Application, and mixing them up causes a lot of confusion.

StatusQuestionValues
SyncDoes the cluster match what Git says?Synced, OutOfSync
HealthAre the resources actually working?Healthy, Progressing, Degraded, Missing, Suspended

Synced but Degraded means Argo CD applied exactly what you committed and what you committed is broken, for example an image that crash-loops. OutOfSync but Healthy means the cluster works but differs from Git: either a commit has not been applied yet, or someone changed the cluster by hand.

Three settings under syncPolicy.automated decide how assertive Argo CD is.

  • automated present: new commits are applied without anyone pressing Sync. Without it, Argo CD only reports OutOfSync and waits.
  • prune: true: a resource deleted from Git is deleted from the cluster. It is off by default, because deleting is dangerous, but without it removed manifests linger forever.
  • selfHeal: true: manual changes to managed resources are reverted to match Git. This is what makes Git the real source of truth instead of a suggestion.
bash
argocd login localhost:8080
argocd app list
argocd app get orders-staging              # sync and health, resource by resource
argocd app diff orders-staging             # what differs between Git and the cluster
argocd app sync orders-staging             # apply now
argocd app history orders-staging          # deployed revisions
argocd app wait orders-staging --health    # block until healthy (useful in scripts)
Watch out

With selfHeal on, an emergency kubectl edit during an incident is undone within minutes. Agree in advance how hotfixes work: either a fast-tracked commit, or a documented procedure for pausing automated sync on that one Application and turning it back on afterwards.

Repositories and promotion

Keep deployment configuration in a separate repository from application source code. The two change at different rates and for different reasons, and keeping them apart stops every image bump from triggering the application's CI, which would otherwise loop. It also lets you give fewer people write access to what runs in production.

text
shop-deploy/
  apps/
    orders/
      base/                     # Deployment, Service, shared by all environments
        kustomization.yaml
      overlays/
        staging/kustomization.yaml    # image tag + staging patches
        prod/kustomization.yaml       # image tag + prod patches
  argocd/
    orders-staging.yaml         # the Application objects themselves
    orders-prod.yaml

Use directories per environment, not long-lived branches. Branch-per-environment looks tidy but leads to merge conflicts, drift between branches and no clear answer to what differs between staging and production. With directories, that answer is a diff of two folders.

The full delivery flow then looks like this.

  1. A developer merges to the application repository. CI tests, builds the image, tags it with the commit SHA and pushes it.
  2. CI's last step commits one line to the deploy repository: the new tag in overlays/staging. That commit is the deployment.
  3. Argo CD sees the commit and syncs staging. Health checks and smoke tests run.
  4. Promotion is a pull request that copies the same tag into overlays/prod. Review and merge are the approval gate, and the merge is the production deploy.
The CI step that deploys to staging
bash
git clone https://github.com/acme/shop-deploy.git && cd shop-deploy/apps/orders/overlays/staging
kustomize edit set image ghcr.io/acme/orders-api="ghcr.io/acme/orders-api:${GIT_SHA}"
git commit -am "orders: deploy ${GIT_SHA} to staging"
git push

When you have dozens of Applications, do not create them by hand. The app-of-apps pattern uses one parent Application whose source is a directory of Application manifests, and an ApplicationSet generates Applications from a template, for example one per directory in the repository or one per cluster.

Rollbacks, secrets and drift

Rollback is git revert. Reverting the commit that introduced the bad version makes Git describe the previous state again, and Argo CD applies it. The rollback is reviewed and audited like any other change, which is the same rule as in the Git module: shared history moves forward. Argo CD also has argocd app rollback, but it requires automated sync to be off and leaves Git out of step with the cluster, so treat it as an emergency measure.

Secrets are the hard part, because everything must be in Git and secrets must not be readable there. There are two established answers.

  • Encrypt what you commit. Sealed Secrets encrypts a Secret with a key that only the in-cluster controller holds, so the sealed form is safe in Git. SOPS encrypts the values of a YAML file with a cloud KMS key or an age key.
  • Commit only a reference. External Secrets Operator reads a manifest that says which entry in AWS Secrets Manager, Vault or a similar store to fetch, and creates the Kubernetes Secret inside the cluster. Nothing sensitive ever touches Git, and rotation happens in one place.

Drift becomes visible and manageable. Some differences are legitimate: a HorizontalPodAutoscaler changes spec.replicas, and a controller may inject fields. If you declare a replica count in Git and an autoscaler manages it, the two fight, and the Application flaps between Synced and OutOfSync. Omit replicas from the manifest, or tell Argo CD to ignore that field.

yaml
spec:
  ignoreDifferences:
    - group: apps
      kind: Deployment
      jsonPointers:
        - /spec/replicas
Tip

Alert on Applications that stay OutOfSync or Degraded. Argo CD exposes Prometheus metrics, and its notifications component can post sync results to chat. A GitOps setup nobody watches fails quietly.

Hands-on practice

Deploy by commit, and watch the cluster heal itself

  1. On your local cluster, install Argo CD into the argocd namespace, port-forward the server, and log in to the UI and the argocd CLI with the initial admin password.
  2. Create a Git repository with apps/orders/base and overlays/staging using Kustomize, or reuse your Helm chart from the previous module.
  3. Write an Application manifest pointing at the staging path with no automated sync. Apply it, observe OutOfSync, run argocd app diff, then sync manually.
  4. Enable automated with prune and selfHeal. Change the image tag in Git, push, and watch the rollout happen with no kubectl or pipeline involved.
  5. Test self-heal: kubectl scale the Deployment, or delete its Service, and time how long Argo CD takes to restore it. Then remove a manifest from Git and confirm pruning deletes the resource.
  6. Commit a broken image tag. Observe Synced with Degraded, explain why those two are not contradictory, then roll back with git revert.
  7. Add an overlays/prod directory and a second Application. Promote a version from staging to production through a pull request that changes only the image tag.
Cheat sheet

GitOps with Argo CD — at a glance

Main things to focus on

  • Four principles: declarative, versioned and immutable, pulled automatically, continuously reconciled.
  • Pull beats push on security: cluster credentials never leave the cluster, and CI only writes to Git and the registry.
  • Sync status is about matching Git. Health status is about working. Synced plus Degraded means you committed something broken.
  • automated applies commits, prune deletes what Git no longer has, selfHeal reverts manual changes.
  • Separate deploy repository; directories per environment, not branches.
  • A deployment is a commit, a promotion is a pull request, a rollback is git revert.
  • Never commit plaintext secrets: seal or encrypt them, or commit only a reference to an external store.
  • Do not declare fields that another controller owns, such as replicas under an autoscaler.

argocd CLI

argocd login HOSTAuthenticate the CLI
argocd app listAll Applications with sync and health status
argocd app get NAMEDetail, resource by resource
argocd app diff NAMEDifferences between Git and the live cluster
argocd app sync NAMEApply the desired state now (--prune to delete extras)
argocd app wait NAME --healthBlock until healthy; useful in scripts
argocd app history NAMERevisions that have been deployed
argocd app set NAME --sync-policy nonePause automated sync, e.g. during an incident

Application spec

spec.source.repoURL / targetRevision / pathWhere the desired state lives
spec.destination.server / namespaceWhere it is applied
spec.projectAppProject that restricts allowed repos and destinations
syncPolicy.automated: {}Apply new commits automatically
syncPolicy.automated.prune: trueDelete resources removed from Git
syncPolicy.automated.selfHeal: trueRevert manual changes in the cluster
syncPolicy.syncOptions: [CreateNamespace=true]Create the destination namespace if missing
spec.ignoreDifferencesFields to leave out of the comparison

Status vocabulary

Synced / OutOfSyncCluster matches Git / cluster differs from Git
HealthyResources report ready
ProgressingStill rolling out; not yet ready
DegradedFailed: crash loops, failed rollout, unavailable replicas
MissingDefined in Git, absent from the cluster
Synced + DegradedGit was applied correctly; what Git says is broken

Workflow building blocks

kustomize edit set image NAME=NAME:TAGBump an image tag in an overlay from CI
git revert COMMIT && git pushRoll back a deployment
argocd.argoproj.io/sync-wave: "1"Annotation that orders resources within a sync
app-of-appsA parent Application whose source is a folder of Applications
ApplicationSetGenerate Applications from a template: per folder, per cluster
Sealed Secrets / SOPS / External Secrets OperatorThree ways to keep secrets out of plaintext Git

Common pitfalls

  • Leaving prune off and wondering why deleted manifests keep running in the cluster.
  • Using a branch per environment, which ends in merge conflicts and unexplained differences between environments.
  • Keeping application code and deployment config in one repository, so image bumps retrigger CI in a loop.
  • Declaring replicas in Git while an autoscaler manages it, making the Application flap.
  • Hot-fixing with kubectl edit under selfHeal, then being surprised when the fix disappears.
  • Reading Synced as "everything is fine" without looking at health.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →