A production-focused Argo CD and GitOps guide: declarative Applications and ApplicationSets, app-of-apps, sync waves and hooks, automated self-heal and prune, progressive delivery with Argo Rollouts, projects/RBAC, and secure secrets — with copy-paste examples.
CI gets your code built and tested; CD gets it safely into production. In 2026, the dominant answer for Kubernetes delivery is GitOps with Argo CD: Git is the single source of truth for what should be running, and Argo CD continuously reconciles the cluster to match. Done well, this gives you auditable, self-healing, reviewable deployments. Done casually, it becomes a pile of hand-edited Application manifests, a controller with cluster-admin everywhere, and secrets committed in plaintext. The best practice is to treat Argo CD as a platform product with declarative apps, scoped projects, progressive delivery, and disciplined secrets — not a fancier kubectl apply.
This guide covers the practices that separate reliable GitOps setups from fragile ones, each with a copy-paste example. The principles — declarative everything, least privilege, progressive rollout, and drift correction — apply to any Kubernetes footprint.
The foundation of GitOps is that the desired state lives in Git, not in someone's terminal history. Define each workload as an Argo CD Application that points at a repo path and a target cluster/namespace. Never kubectl apply into an Argo-managed namespace — that is exactly the drift Argo exists to eliminate.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: payments
namespace: argocd
spec:
project: payments-team
source:
repoURL: https://github.com/my-org/gitops-config.git
targetRevision: main
path: apps/payments/overlays/prod
destination:
server: https://kubernetes.default.svc
namespace: payments
syncPolicy:
syncOptions:
- CreateNamespace=true
Keep application config (the "what") separate from application source code (the "how it's built"). A dedicated GitOps config repo, distinct from your app repos, keeps the deployment history clean and reviewable.
The separation between config and source is more than tidiness — it is what makes GitOps auditable. When deployment state lives in its own repo, the Git history of that repo is your deployment history: every change to what runs in production is a commit with an author, a timestamp, a diff, and a review. There is no separate "who deployed what when" system to consult and no way to change production without leaving a trace. It also decouples the two cadences that naturally differ: application code changes constantly as developers work, while the deployed configuration changes deliberately when you promote a tested image. Mixing them in one repo couples every code push to a potential production change and muddies both histories.
Hand-writing an Application per service per environment does not scale. ApplicationSet generates them from a template — over a list, a Git directory, or your fleet of clusters — so onboarding a new service or region is a one-line change.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: payments-envs
namespace: argocd
spec:
generators:
- list:
elements:
- { env: staging, cluster: https://staging.k8s.internal }
- { env: prod, cluster: https://prod.k8s.internal }
template:
metadata:
name: 'payments-{{env}}'
spec:
project: payments-team
source:
repoURL: https://github.com/my-org/gitops-config.git
targetRevision: main
path: 'apps/payments/overlays/{{env}}'
destination:
server: '{{cluster}}'
namespace: payments
The Git-directory and cluster generators are especially powerful: add a folder or register a cluster, and the matching Applications appear automatically.
For a whole platform, manage a root Application that points at a directory of child Applications. This app-of-apps pattern gives you one place to bootstrap an entire environment and a clear ownership tree.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: platform-root
namespace: argocd
spec:
project: platform
source:
repoURL: https://github.com/my-org/gitops-config.git
targetRevision: main
path: bootstrap/prod # a directory of child Application manifests
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated: { prune: true, selfHeal: true }
Bootstrapping a fresh cluster becomes: install Argo CD, apply one root Application, and let it pull in everything else.
By default Argo CD is powerful enough to deploy anything anywhere — a serious risk in a shared cluster. AppProjects constrain which repos, clusters, namespaces, and resource kinds a set of apps may touch, and RBAC constrains who can sync them.
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: payments-team
namespace: argocd
spec:
sourceRepos:
- https://github.com/my-org/gitops-config.git
destinations:
- server: https://prod.k8s.internal
namespace: payments
clusterResourceWhitelist: [] # no cluster-scoped resources
namespaceResourceBlacklist:
- { group: "", kind: ResourceQuota }
roles:
- name: deployer
policies:
- p, proj:payments-team:deployer, applications, sync, payments-team/*, allow
Give each team a project scoped to their repos and namespaces. This is how you run many teams on one Argo CD without letting one team's misconfiguration reach another's workloads.
The threat AppProjects address is real and easy to overlook: Argo CD runs with broad cluster permissions so it can deploy anything, which means a mistake or a malicious manifest in one team's repo could, by default, create a ClusterRole, deploy into another team's namespace, or point at a different cluster entirely. Projects turn Argo's power into a set of guardrails — this set of apps may only pull from these repos, deploy to these namespaces on these clusters, and create these kinds of resources. The clusterResourceWhitelist and namespaceResourceBlacklist are especially valuable in multi-tenant clusters: denying cluster-scoped resources by default means a team cannot accidentally grant itself extra privileges through a manifest. Combined with RBAC roles that say who may sync, you get true multi-tenancy on a single Argo CD instance.
The payoff of GitOps is a cluster that fixes itself. automated sync applies changes as they land in Git; selfHeal reverts manual drift; prune deletes resources removed from Git. Turn these on deliberately, with a retry backoff.
syncPolicy:
automated:
prune: true # delete resources removed from Git
selfHeal: true # revert out-of-band kubectl changes
retry:
limit: 5
backoff: { duration: 5s, factor: 2, maxDuration: 3m }
syncOptions:
- PruneLast=true # prune only after new resources are healthy
selfHeal is what makes drift impossible to sustain: edit a Deployment by hand and Argo puts it back within minutes. Use PruneLast=true so deletions happen only after the replacement is healthy, avoiding a gap in coverage.
Enable these deliberately rather than reflexively, because each has a failure mode worth understanding. prune is powerful and slightly scary: remove a manifest from Git and Argo deletes the live resource, which is exactly what you want for clean promotion but unforgiving if someone deletes a file by accident — so protect the config repo with branch protection and reviews, since it now has production-delete authority. selfHeal is what finally kills the "someone hotfixed it with kubectl and forgot to tell anyone" class of incident, but it also means emergency manual changes get reverted within minutes, so your break-glass procedure has to go through Git too. The retry backoff keeps a transiently-failing sync from hammering the API server. Turned on with these guardrails, automated sync is the difference between GitOps as a description and GitOps as an enforced, self-correcting system.
Complex apps need ordering — a database migration before the app, CRDs before the resources that use them. Sync waves sequence resources, and resource hooks run jobs at defined phases (PreSync, Sync, PostSync).
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrate
annotations:
argocd.argoproj.io/hook: PreSync # run before the app syncs
argocd.argoproj.io/hook-delete-policy: HookSucceeded
argocd.argoproj.io/sync-wave: "-1" # earlier than the workload
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: my-org/payments:1.8.0
command: ["./migrate.sh"]
A failed PreSync migration blocks the sync, so the app never rolls out against an un-migrated database.
A plain Argo CD sync is still effectively all-at-once at the workload level. For real safety on high-traffic services, replace the Deployment with an Argo Rollouts Rollout that does canary or blue-green with automated analysis and rollback.
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: payments
spec:
replicas: 6
strategy:
canary:
steps:
- setWeight: 20
- pause: { duration: 5m }
- analysis:
templates: [{ templateName: error-rate }]
- setWeight: 50
- pause: { duration: 5m }
- setWeight: 100
selector:
matchLabels: { app: payments }
template:
metadata: { labels: { app: payments } }
spec:
containers:
- name: payments
image: my-org/payments:1.8.0
The analysis step queries your metrics (error rate, latency) during the bake window and automatically rolls back if they regress. A rollout without automated analysis is manual hope.
This is the piece that closes the gap between "GitOps deployed my change" and "GitOps deployed my change safely." A standard Kubernetes rolling update will happily replace every pod with a broken image as fast as the readiness probe allows — and a shallow readiness probe often reports healthy while the app is quietly returning errors. Argo Rollouts shifts a small slice of real traffic first, holds it there while an AnalysisTemplate queries Prometheus (or Datadog, CloudWatch, and others) for your actual SLOs, and only advances if the numbers hold. If error rate or latency crosses the threshold, it aborts and shifts traffic back automatically, with no human in the loop and no pager going off. Blue-green is the same idea for cases where you want a full standby stack and an instant switch. Either way, the release decision becomes data-driven rather than a held breath.
The one thing you must not put in a GitOps repo in plaintext is a secret. Use Sealed Secrets, SOPS, or the External Secrets Operator so Git holds only encrypted or referenced values, and the plaintext lives in a real secrets store.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: payments-db
namespace: payments
spec:
refreshInterval: 1h
secretStoreRef: { name: vault-backend, kind: SecretStore }
target: { name: payments-db }
data:
- secretKey: password
remoteRef: { key: secret/payments/prod, property: password }
Git references the secret by path; the operator fetches the real value from Vault at runtime. Nothing sensitive is ever committed, and rotation happens in the vault, not in a pull request.
The three common approaches trade off differently and it is worth choosing consciously. Sealed Secrets encrypts values with a controller-held key so the ciphertext is safe to commit — simple and self-contained, but rotation means re-sealing. SOPS encrypts with a KMS key and integrates well with GitOps tooling, keeping the encrypted file in Git. The External Secrets Operator shown above keeps no secret material in Git at all — only a reference — and delegates storage and rotation entirely to a real secrets manager like Vault, AWS Secrets Manager, or Azure Key Vault, which is usually the strongest option for production. Whatever you pick, the non-negotiable rule is that plaintext secrets never enter the repository: a GitOps repo is widely readable by design, and a committed credential is effectively already leaked, since Git history preserves it even after deletion.
Argo CD continuously reports whether each app is Synced/OutOfSync and Healthy/Degraded. Wire those into alerting so drift and failed syncs page someone, rather than being discovered during an incident.
# argocd-notifications: alert when an app drifts or a sync fails
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-notifications-cm
namespace: argocd
data:
trigger.on-out-of-sync: |
- when: app.status.sync.status == 'OutOfSync'
send: [app-out-of-sync]
template.app-out-of-sync: |
message: "{{.app.metadata.name}} is OutOfSync in {{.app.spec.destination.namespace}}"
An OutOfSync app with selfHeal off means someone changed the cluster by hand or Git and the cluster disagree — either way, you want to know immediately.
Promotion in GitOps is a Git operation: update the image tag or overlay in the next environment's path and let Argo reconcile. This keeps every environment change reviewed and auditable, and makes rollback a git revert.
# apps/payments/overlays/prod/kustomization.yaml
images:
- name: my-org/payments
newTag: "1.8.0" # promote by bumping this via PR; Argo syncs it
Because every deploy is a commit, DORA metrics fall out naturally: deployment frequency (merges to env paths), lead time (commit to synced), change failure rate (rollouts that aborted), and time to restore (git revert to healthy). Review sync health, rollback frequency, and drift alerts weekly.
kubectl apply into Argo-managed namespaces. That is the drift Argo exists to remove; use Git.selfHeal/prune left off. Then GitOps is not actually enforcing anything.automated sync with selfHeal and prune (PruneLast) is enabled deliberately.git revert.In 2026, the teams that win with Argo CD combine GitOps convenience with real discipline: declarative apps, scoped projects, progressive delivery, and secrets that never touch Git in plaintext. These are the compounding habits that turn continuous reconciliation into a delivery platform you can trust.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A lightweight, whiteboard-friendly way to find design-level security flaws that scanners miss, using STRIDE, data-flow diagrams, and four plain questions.
Stop re-downloading the same packages on every CI run by caching dependencies with keys that actually hit.
Explore more articles in this category
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.
Stop stashing long-lived AWS access keys in GitHub secrets and let OIDC hand your workflows short-lived, scoped credentials instead.
Evergreen posts worth revisiting.