How to Set Up Argo CD for GitOps: Step-by-Step Guide (2026)

Oct 20, 2026 · 14 min · Ajay Kumar

This guide is for engineers who have a Kubernetes cluster and want a GitOps setup they will not have to rebuild in six months. By the end you will have Argo CD 3.5 installed with Helm, a Git repository laid out for multiple teams and environments, a single root Application that bootstraps everything through an ApplicationSet, automated sync with waves and a migration hook, SSO with group-based RBAC, and a short list of commands for the three statuses that cause most tickets: OutOfSync, Degraded and ComparisonError. Everything here was checked against Argo CD v3.5.5 (12 October 2026) and the argo-cd Helm chart 10.10.5, whose rendered output I inspected with helm template. You need kubectl and Helm 3.x or 4.x, cluster-admin on a cluster running Kubernetes 1.33 to 1.36 (the tested range for 3.5), and a Git repository you can push to. If you are still choosing between Argo CD and Flux, read Argo CD vs Flux in 2026 first.

One root Application bootstraps everything, then the controller keeps it reconciled Git repo bootstrap/ + apps/ Root app app-of-apps ApplicationSet git dir generator Child apps Helm or Kustomize Sync to cluster PreSync, waves, Post Reconcile loop Every 180s plus jitter the controller re-diffs live vs Git: selfHeal reverts drift, prune deletes.
The layout built in this guide. The 180-second interval is the value set in Step 1; Argo CD's default is 120s plus up to 60s of jitter, per the chart's values and the auto-sync docs.

Step 1: Install Argo CD 3.5 with Helm

The project publishes plain manifests too (the 3.5.0 release notes use kubectl apply --server-side --force-conflicts), but Helm makes upgrades and configuration reviewable, and you will want to hand the install to Argo CD later. Pin the chart version. Chart 10.10.5 ships appVersion: v3.5.5 and requires Kubernetes 1.25 or newer.

# values-argocd.yaml  (argo-cd chart 10.10.5 -> Argo CD v3.5.5)
global:
  domain: argocd.example.com        # used for the server URL and ingress host

configs:
  params:
    server.insecure: true           # terminate TLS at your Gateway/Ingress, not in argocd-server
  cm:
    admin.enabled: true             # keep until SSO works (Step 8), then turn off
    timeout.reconciliation: 180s    # how often Git is polled; webhooks make this mostly irrelevant
  rbac:
    policy.default: role:readonly   # authenticated users see things but cannot change them

# High availability is a separate decision: set redis-ha.enabled=true and
# raise controller/server/repoServer replicas when you have >1 team on it.
helm repo add argo https://argoproj.github.io/argo-helm
helm repo update

helm upgrade --install argocd argo/argo-cd \
  --version 10.10.5 \
  --namespace argocd --create-namespace \
  --values values-argocd.yaml \
  --wait

kubectl get deploy,statefulset -n argocd

You should see seven workloads become ready: argocd-server, argocd-repo-server, argocd-applicationset-controller, argocd-notifications-controller, argocd-dex-server, argocd-redis, and the argocd-application-controller StatefulSet. The chart also installs three CRDs: applications, applicationsets and appprojects in the argoproj.io group.

To expose the UI, point an HTTPRoute or Ingress at the argocd-server Service on port 80 (the chart maps both 80 and 443 to the same container port when server.insecure is set). If you are still on ingress-nginx, note that it is retired; migrating Ingress NGINX to Gateway API covers the replacement.

Step 2: Install the argocd CLI and log in

brew install argocd            # or download the v3.5.5 binary from the GitHub release
argocd version --client

# The UI and API on localhost (plain HTTP because server.insecure=true)
kubectl port-forward svc/argocd-server -n argocd 8080:80 &

argocd admin initial-password -n argocd
argocd login localhost:8080 --username admin --plaintext
argocd account update-password

# The getting-started docs recommend deleting this once the password is changed
kubectl delete secret argocd-initial-admin-secret -n argocd

If you skipped server.insecure, forward 8080:443 and log in with --insecure instead of --plaintext. The getting started guide notes that argocd-initial-admin-secret only holds the generated password in clear text and is recreated if needed.

Step 3: Lay out the GitOps repository

Most Argo CD pain I see traces back to repository layout, not Argo CD. Keep two things separate: what Argo CD should manage (bootstrap), and what the applications are (apps). One repository is fine to start.

platform-gitops/
├── bootstrap/
│   ├── root-app.yaml            # applied once by hand (Step 6)
│   ├── projects/
│   │   └── team-a.yaml          # AppProject (Step 4)
│   └── appsets/
│       └── prod-apps.yaml       # ApplicationSet (Step 6)
└── apps/
    ├── guestbook/               # Kustomize app
    │   ├── base/
    │   └── overlays/
    │       ├── staging/
    │       └── prod/
    └── podinfo/                 # Helm app: values only, chart comes from a registry
        └── overlays/
            └── prod/
                └── values.yaml

Do not put environment branches in Git (staging branch, prod branch). Directories per environment are easier to diff, review and promote with a normal pull request.

Step 4: Create an AppProject per team

The default project allows any repo to deploy anything anywhere. Create a project per team and restrict sources, destinations and cluster-scoped kinds. Field names below match the project specification.

# bootstrap/projects/team-a.yaml
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: team-a
  namespace: argocd
spec:
  description: Team A workloads
  sourceRepos:
    - https://github.com/example-org/platform-gitops.git
    - ghcr.io/stefanprodan/charts          # OCI Helm registry for podinfo
  destinations:
    - server: https://kubernetes.default.svc
      namespace: team-a-*
  clusterResourceWhitelist:
    - group: ""
      kind: Namespace                      # allow CreateNamespace=true, nothing else cluster-wide
  namespaceResourceBlacklist:
    - group: ""
      kind: ResourceQuota                  # quotas are owned by the platform team
  orphanedResources:
    warn: true                             # flag objects in team namespaces that Git doesn't know about

Step 5: Deploy a Kustomize app and a Helm app

Argo CD detects Kustomize when the path contains a kustomization.yaml and runs the equivalent of kustomize build. For Helm, it runs helm template and applies the output, so helm list will not show the release, and Helm hooks are mapped onto Argo CD hooks. Since 3.5, every chart renders with Helm 4.2.x and spec.source.helm.version: v3 is ignored (3.4 to 3.5 upgrade notes), so test charts with Helm 4 locally before upgrading.

The pattern worth learning is multiple sources: chart from a registry, values from your Git repo, referenced with $values.

# apps/podinfo as a standalone Application (Step 6 generates these instead)
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: podinfo-prod
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io   # deleting the app deletes its resources
spec:
  project: team-a
  sources:
    - repoURL: ghcr.io/stefanprodan/charts
      chart: podinfo
      targetRevision: 6.15.0                   # pin chart versions, never "*"
      helm:
        releaseName: podinfo
        valueFiles:
          - $values/apps/podinfo/overlays/prod/values.yaml
    - repoURL: https://github.com/example-org/platform-gitops.git
      targetRevision: main
      ref: values                              # makes $values point at this repo
  destination:
    server: https://kubernetes.default.svc
    namespace: team-a-podinfo
  syncPolicy:
    syncOptions:
      - CreateNamespace=true

podinfo 6.15.0 was the current chart in October 2026. Note that repoURL for an OCI Helm registry has no oci:// prefix, per the Helm docs. Apply it with kubectl apply -f, then run argocd app get podinfo-prod and argocd app sync podinfo-prod. A manual first sync is a good habit: you see the diff before you trust automation with it.

Step 6: Bootstrap with app-of-apps and an ApplicationSet

Hand-applying Applications does not scale. Apply one root Application by hand; it manages everything in bootstrap/, including an ApplicationSet that generates one Application per app directory. The generator syntax follows the Git generator docs.

# bootstrap/root-app.yaml  (the only thing you ever kubectl apply)
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root
  namespace: argocd
spec:
  project: default            # the platform team's root; team apps use their own project
  source:
    repoURL: https://github.com/example-org/platform-gitops.git
    targetRevision: main
    path: bootstrap
    directory:
      recurse: true
      exclude: root-app.yaml  # don't let the root app manage itself
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
# bootstrap/appsets/prod-apps.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: team-a-prod
  namespace: argocd
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]
  generators:
    - git:
        repoURL: https://github.com/example-org/platform-gitops.git
        revision: main
        directories:
          - path: apps/*/overlays/prod
  template:
    metadata:
      name: '{{index .path.segments 1}}-prod'           # e.g. guestbook-prod
      finalizers:
        - resources-finalizer.argocd.argoproj.io
    spec:
      project: team-a
      source:
        repoURL: https://github.com/example-org/platform-gitops.git
        targetRevision: main
        path: '{{.path.path}}'
      destination:
        server: https://kubernetes.default.svc
        namespace: 'team-a-{{index .path.segments 1}}'
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true
          - ServerSideApply=true
        retry:
          limit: 5
          backoff:
            duration: 10s
            factor: 2
            maxDuration: 3m
kubectl apply -f bootstrap/root-app.yaml
argocd app list
# NAME                    PROJECT  SYNC STATUS  HEALTH STATUS
# argocd/root             default  Synced       Healthy
# argocd/guestbook-prod   team-a   Synced       Healthy

Two things to know. First, the ApplicationSet owns its generated Applications, so changing syncPolicy.automated on a generated app has no effect, as the auto-sync docs point out; change the template. Second, the Helm app in apps/podinfo needs multiple sources, so give Helm apps their own ApplicationSet with a sources: template rather than forcing one template to fit both.

If you later let Argo CD manage its own Helm release, the 3.3 release notes say that Application must have ServerSideApply=true, or upgrades fail.

Step 7: Sync policies, sync waves and hooks

automated.prune deletes objects removed from Git; automated.selfHeal reverts manual changes (the controller retries after 5 seconds by default). Leave both off for a week on a new app, then turn them on. Note that rollback from the UI is unavailable while automated sync is enabled; you roll back with git revert.

Ordering uses the argocd.argoproj.io/sync-wave annotation: integers, lowest first, default 0, negatives allowed, with a 2-second pause between waves, per the sync waves docs. Hooks run in phases: PreSync, Sync, PostSync, SyncFail, plus PreDelete and PostDelete during app deletion.

# A schema migration that must succeed before the new Deployment rolls out
apiVersion: batch/v1
kind: Job
metadata:
  name: db-migrate
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation   # the default; replace the old Job each sync
spec:
  backoffLimit: 1
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: ghcr.io/example-org/guestbook-migrate:1.4.2
          args: ["migrate", "up"]
---
# Config first (wave -1), then the app (wave 0, the default)
apiVersion: v1
kind: ConfigMap
metadata:
  name: guestbook-config
  annotations:
    argocd.argoproj.io/sync-wave: "-1"
data:
  LOG_LEVEL: info

If the PreSync Job fails, the sync stops and the Deployment is never touched, which is exactly what you want from a migration.

Step 8: SSO and RBAC

Use your identity provider directly with oidc.config and switch off the bundled Dex unless you need a connector only Dex has. The callback URL to register at the IdP is https://argocd.example.com/auth/callback. The client secret lives in a separate Secret referenced as $<secret>:<key>, which must carry the label app.kubernetes.io/part-of: argocd, per the user management docs.

kubectl create secret generic argocd-oidc -n argocd \
  --from-literal=clientSecret="$OIDC_CLIENT_SECRET"
kubectl label secret argocd-oidc -n argocd app.kubernetes.io/part-of=argocd
# add to values-argocd.yaml, then helm upgrade with the same --version 10.10.5
configs:
  cm:
    admin.enabled: false            # only after you have logged in via SSO once
    oidc.config: |
      name: Okta
      issuer: https://example.okta.com
      clientID: 0oa1example
      clientSecret: $argocd-oidc:clientSecret
      requestedScopes: ["openid", "profile", "email", "groups"]
  rbac:
    policy.default: role:readonly
    scopes: "[groups]"
    policy.csv: |
      p, role:platform, applications, *, */*, allow
      p, role:platform, applicationsets, *, */*, allow
      p, role:platform, logs, get, */*, allow
      p, role:team-a, applications, get, team-a/*, allow
      p, role:team-a, applications, sync, team-a/*, allow
      p, role:team-a, logs, get, team-a/*, allow
      g, platform-admins, role:platform
      g, team-a-devs, role:team-a
dex:
  enabled: false

Since 3.0, update and delete on an Application no longer cover its child resources, and logs need an explicit logs, get line, as the RBAC docs describe. Test policies before shipping them:

# Extract the CSV from your values, then ask the policy engine
argocd admin settings rbac validate --policy-file policy.csv
argocd admin settings rbac can team-a-devs sync applications 'team-a/guestbook-prod' --policy-file policy.csv
# Yes
argocd admin settings rbac can team-a-devs delete applications 'team-a/guestbook-prod' --policy-file policy.csv
# No

Troubleshooting OutOfSync, Degraded and ComparisonError

Start every investigation with the same three commands. They tell you whether the problem is rendering, diffing or health.

argocd app get guestbook-prod --hard-refresh     # re-render from Git, bypass the manifest cache
argocd app diff guestbook-prod                    # what Argo CD thinks differs
kubectl get application guestbook-prod -n argocd -o jsonpath='{.status.conditions}'

ComparisonError means Argo CD could not produce or compare manifests: repo credentials, a Helm or Kustomize error, or a CRD the cluster does not have yet. The message in .status.conditions is usually specific. OutOfSync that never clears is almost always a field the cluster sets or mutates after apply. Ignore it explicitly, or switch to server-side diff, which is opt-in per app via the argocd.argoproj.io/compare-options: ServerSideDiff=true annotation (diff strategies docs):

spec:
  ignoreDifferences:
    - group: apps
      kind: Deployment
      jsonPointers:
        - /spec/replicas          # owned by the HPA, not Git
  syncPolicy:
    syncOptions:
      - RespectIgnoreDifferences=true   # also skip those fields during sync

Degraded is a health verdict about the workload, not Argo CD: a crash-looping pod, a failed Job, an unbound PVC. Follow it down with kubectl describe and the CrashLoopBackOff guide.

Symptom Likely cause Fix
ComparisonError: ... authentication required Repo credentials missing or expired argocd repo add with a token or GitHub App; check the repo Secret has argocd.argoproj.io/secret-type: repository
ComparisonError mentioning a missing kind CRD not installed yet Put the CRD in an earlier sync wave, or add SkipDryRunOnMissingResource=true
OutOfSync on spec.replicas forever HPA scales the Deployment ignoreDifferences on /spec/replicas plus RespectIgnoreDifferences=true
OutOfSync on defaulted or webhook-mutated fields Client-side diff cannot see server defaults Enable ServerSideDiff=true per app, or controller.diff.server.side: "true" globally
Health stuck Progressing on an Ingress No load balancer address ever assigned Fix the controller or LB; a custom health check is the last resort
Degraded after sync Pods crash, Job failed, PVC pending kubectl describe the unhealthy child shown in argocd app get
Sync stuck on a hook PreSync Job hangs or retries forever Set backoffLimit and activeDeadlineSeconds; argocd app terminate-op to cancel
Self-managed Argo CD upgrade fails after 3.3 Client-side apply on its own Application Add ServerSideApply=true to that Application
SSO users see no apps No role for their group, empty default Check scopes includes groups, map the group with g, and test with rbac can
Helm chart renders differently after 3.5 Helm 4.2.x replaced Helm 3 Render with Helm 4 locally and fix the chart; helm.version: v3 is ignored

FAQ

Should I install Argo CD with Helm or plain manifests?

Either works. The official manifests are the reference and are what the release notes show, while the community Helm chart makes configuration reviewable as values and is easier to hand to Argo CD itself. Pin the version in both cases.

What is the difference between app-of-apps and ApplicationSet?

App-of-apps is a pattern: an Application whose manifests are other Applications. ApplicationSet is a controller that generates Applications from generators such as Git directories, clusters or pull requests. Most setups use both: a root app that creates the ApplicationSets.

Why does my Argo CD app stay OutOfSync after a successful sync?

Something changes the object after Argo CD applies it: a controller, a mutating webhook, or an autoscaler. Run argocd app diff to see the field, then ignore it with ignoreDifferences or switch to server-side diff.

How do I handle secrets with Argo CD?

Argo CD's own docs recommend populating secrets on the destination cluster with External Secrets Operator, Sealed Secrets or the Secrets Store CSI Driver, and caution against injecting them at manifest-generation time. The External Secrets Operator with Vault guide shows the setup.

When an Argo CD app misbehaves in production, the first thing I check is .status.conditions, because a ComparisonError there explains most "it won't sync" reports in one line. The second is whether someone added an exception to ignoreDifferences that hides a real change. I keep automated sync with prune and self-heal on for everything generated by ApplicationSets, keep the root app the only thing anyone applies by hand, and treat the Git repository's branch protection as part of the production change process, because with GitOps the merge button is the deploy button. The reference material is the Argo CD operator manual, the upgrade notes before every minor version bump, and the argo-helm chart repository for values. If you deploy progressively, canary deployments with Argo Rollouts is the natural next step.

Related: Infrastructure as Code in 2026, CI/CD in 2026 and Kubernetes as the Agent Control Plane in 2026.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related