DevOpsAdvanced

Argo CD and GitOps: Continuous Delivery for Kubernetes

How Argo CD implements GitOps in practice — Applications, sync policies, App of Apps, and the real workflow of shipping to Kubernetes through Git.

DevFieldGuideJuly 31, 20269 min read
Share:

Argo CD is the most widely adopted implementation of GitOps on Kubernetes — a controller that runs inside your cluster, continuously compares live state to what's declared in Git, and reconciles the difference.

Prerequisites
  • A working Kubernetes cluster with kubectl access.
  • A Git repository containing Kubernetes manifests, a Helm chart, or a Kustomize overlay for at least one application.
  • Familiarity with basic Kubernetes objects (Deployments, Services) — Argo CD manages them, it doesn't replace understanding them.

Installing Argo CD

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

Argo CD ships its own UI, CLI, and API server, all running as pods inside the argocd namespace it just created. Access the UI by port-forwarding the API server service, or exposing it via an Ingress/LoadBalancer for real team use:

bash
kubectl port-forward svc/argocd-server -n argocd 8080:443

The core object: an Application

Everything in Argo CD revolves around the Application custom resource — it points at a Git source and a cluster/namespace destination, and declares how the two should be kept in sync.

yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/my-org/my-app-config.git
    targetRevision: main
    path: environments/production
  destination:
    server: https://kubernetes.default.svc
    namespace: my-app
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

Two fields inside automated do most of the practical work:

  • prune: true — resources removed from Git get deleted from the cluster too, not just left behind as orphans.
  • selfHeal: true — this is what enforces GitOps principle 4 (continuous reconciliation). Without it, Argo CD only syncs on a Git change; with it, it also corrects manual drift automatically, even if Git itself hasn't changed.

The sync lifecycle

Git changesA merged PR updates a manifest or image tag
Argo CD detects diffPolls the repo (default ~3 min) or receives a webhook
OutOfSyncApplication status flips from Synced
Sync (auto or manual)kubectl-equivalent apply, computed as a real diff
Health checkArgo CD watches rollout status, not just apply success

The last step matters: Argo CD doesn't consider a sync "done" just because kubectl apply succeeded. It waits for real Kubernetes health signals — a Deployment's rollout actually completing, a Service having endpoints — before marking the Application Healthy. A sync that applies cleanly but never becomes healthy (a crash-looping pod, for instance) stays visibly flagged, not silently "successful."

App of Apps: managing many applications declaratively

Once you have more than a handful of services, manually creating Application objects for each one stops scaling. The "App of Apps" pattern solves this by making a parent Argo CD Application whose entire job is to manage a directory of other Application manifests:

yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: platform-apps
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/my-org/platform-config.git
    targetRevision: main
    path: apps
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Adding a new service becomes: add one YAML file describing its Application to the apps/ directory, commit, and the parent Application's own reconciliation loop picks it up and creates it — the entire fleet of services stays declarative, including the fact that they exist at all.

Sync waves: controlling apply order

By default, Argo CD applies resources roughly in Kubernetes-recommended order (namespaces before deployments, for instance), but real dependencies — a database migration Job that must finish before the application Deployment starts — need explicit ordering via sync wave annotations:

yaml
metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "-1"

Lower numbers sync first. A migration Job annotated -1 and the application Deployment left at the default 0 guarantees the migration completes before the Deployment is applied.

Multi-cluster and multi-environment patterns

A single Argo CD instance can manage many clusters — the destination.server field in each Application points at whichever cluster it targets, registered via argocd cluster add. Combined with a config repo layout like environments/staging and environments/production (from the GitOps article), one Argo CD install can drive an entire fleet: staging apps synced automatically on every merge, production apps requiring a manual sync click or a separate promotion PR, using the exact same underlying mechanism with a different syncPolicy.

ApplicationSets: generating Applications from a template

App of Apps works well when you're hand-writing each Application, but it doesn't scale cleanly to "one Application per environment per service" across a growing matrix. ApplicationSet solves this by generating Application resources from a template plus a generator — most commonly a Git directory generator or a cluster list generator:

yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: services
  namespace: argocd
spec:
  generators:
    - git:
        repoURL: https://github.com/my-org/services-config.git
        revision: main
        directories:
          - path: services/*
  template:
    metadata:
      name: "{{path.basename}}"
    spec:
      project: default
      source:
        repoURL: https://github.com/my-org/services-config.git
        targetRevision: main
        path: "{{path}}"
      destination:
        server: https://kubernetes.default.svc
        namespace: "{{path.basename}}"
      syncPolicy:
        automated:
          prune: true

Adding a new service becomes: add a new directory under services/ in the config repo. The ApplicationSet controller notices it on its next reconciliation and generates a full Application for it automatically — no manual Application YAML to write per service, and no risk of one getting forgotten.

Projects: RBAC and blast-radius control

An Argo CD AppProject scopes what a set of Applications is even allowed to do — which source repos they can pull from, which destination clusters/namespaces they can deploy into, and which Kubernetes resource kinds they're permitted to manage:

yaml
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: team-payments
  namespace: argocd
spec:
  sourceRepos:
    - "https://github.com/my-org/payments-*"
  destinations:
    - server: https://kubernetes.default.svc
      namespace: "payments-*"
  clusterResourceWhitelist: []

clusterResourceWhitelist: [] denies cluster-scoped resources (ClusterRoles, CustomResourceDefinitions) entirely for this project — a team's Applications are restricted to namespaced resources within their own namespace pattern, so a misconfigured or malicious manifest in their config repo can't touch anything outside their own blast radius, independent of whatever RBAC the underlying Kubernetes cluster itself enforces.

Notifications: closing the feedback loop

A GitOps deployment happening automatically in the background is only trustworthy if the team actually finds out when it fails. Argo CD Notifications (built into the controller) sends alerts on sync/health state transitions to Slack, email, or a webhook, configured declaratively alongside the rest of the setup:

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-notifications-cm
  namespace: argocd
data:
  trigger.on-sync-failed: |
    - when: app.status.operationState.phase in ['Error', 'Failed']
      send: [slack-sync-failed]
  template.slack-sync-failed: |
    message: "Sync failed for {{.app.metadata.name}}: {{.app.status.operationState.message}}"

Without this, the only signal a sync failed is someone noticing the Argo CD UI shows a red status — fine for a small team watching closely, not reliable once deployments are frequent and the team is larger than whoever happens to have the dashboard open.

Comparing Argo CD to Flux

Argo CD isn't the only mature GitOps controller — Flux (also a CNCF project) implements the same four OpenGitOps principles with a different architectural emphasis: Flux is more modular (separate controllers for source, Kustomize, Helm, and notifications, composed together) and has no bundled UI by default, while Argo CD ships a full web UI and a more monolithic, opinionated Application model out of the box.

AspectArgo CDFlux
UIFull web UI included by defaultNo bundled UI — CLI and Grafana dashboards, or a separate UI project
ArchitectureMore monolithic — one Application CRD drives everythingModular — separate controllers per concern (source, Kustomize, Helm)
Multi-tenancyAppProjectsKubernetes-native RBAC + tenant-scoped controllers
Best forTeams wanting a visual dashboard and simpler mental modelTeams wanting minimal footprint and deep GitOps-toolkit composability

Neither is objectively better — teams that want a visible dashboard for less Kubernetes-fluent stakeholders tend to prefer Argo CD; platform teams building a highly customized internal developer platform on top of GitOps primitives often prefer Flux's more composable pieces.

Common mistakes

Common mistakes
  • Enabling selfHeal: true in production before the team trusts it. It's genuinely powerful, but it means any manual kubectl change gets silently reverted — a legitimate emergency hotfix applied by hand disappears on the next reconciliation unless it's also committed to Git.
  • Putting Helm values.yaml secrets or raw credentials directly in the config repo Argo CD watches. Argo CD syncs exactly what's in Git, including anything sensitive left there in plaintext — use Sealed Secrets or an external secrets operator instead.
  • Forgetting prune: true and assuming removing a resource from Git deletes it from the cluster. Without pruning, deleted manifests just become orphaned, undeclared resources that Argo CD no longer tracks or manages.
  • Treating "Synced" as equivalent to "working." Synced only means the last apply succeeded — check the separate Health status (Healthy/Degraded/Progressing) before assuming a deployment is actually serving traffic correctly.

Practical rollout patterns

Best practices
  1. Use manual sync (not automated) for production Applications until the team has enough operational trust in what auto-sync will do — flip it to automated once change volume and confidence justify it.
  2. Pair Argo CD with a real CI pipeline that only updates the image tag in the config repo — never let CI apply directly to the cluster; that reintroduces the exact push-based credential exposure GitOps exists to remove.
  3. Use sync-wave annotations explicitly for any real ordering dependency (migrations, CRDs before the resources that use them) rather than hoping default ordering happens to work.
  4. Set resource-level health checks for custom resources Argo CD doesn't understand natively — without one, a CRD-based resource can report "Healthy" the instant it's applied, regardless of its actual state.

Frequently Asked Questions

DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in DevOps

View all