How to Set Up Argo CD for GitOps: Step-by-Step Guide (2026)
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.
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
How to Set Up Backstage as an Internal Developer Portal (2026)
Step-by-step Backstage 1.55 setup for 2026: create-app on Node 22 or 24, PostgreSQL, GitHub sign-in, catalog-info.yaml, software templates, TechDocs, a Kubernetes deploy with the official Helm chart, alternatives, and fixes for common errors.
14 minOct 20, 2026How to Do Canary Deployments with Argo Rollouts 1.10 (2026)
Step-by-step Argo Rollouts canary guide for 2026: install v1.10.1 with Helm and the Gateway API plugin v0.17.0, write a Rollout with weighted steps, add a Prometheus AnalysisTemplate, watch automated rollback, and fix the common errors.
14 minOct 20, 2026How to Fix 502 Bad Gateway and 504 Gateway Timeout Errors (2026)
Fix 502 Bad Gateway and 504 Gateway Timeout step by step: find which hop generated the error, read NGINX upstream errors, fix keepalive and idle timeout mismatches (ALB 60s, GCP 600s), Kubernetes readiness and rollout gaps, Gateway API timeouts and Cloudflare 520 to 526.
15 min