# GitOps with Argo CD: Best Practices for 2026

URL: https://www.devopsness.com/blog/gitops-with-argo-cd-best-practices-for-2025
Author: Kiril Urbonas
Published: 2025-05-01
Updated: 2026-10-12
Categories: DevOps
Tags: Kubernetes, Terraform, CI/CD

Summary: The Argo CD 3.x patterns that held up under real traffic in 2026: repo layout, sync policies you can trust, secrets that stay out of git, and the anti-patterns we stopped repeating.

---
# GitOps with Argo CD: Best Practices for 2026

The Argo CD best practices that matter most: keep manifests in a deploy repo separate from app code, generate Applications with ApplicationSets, run automated sync with `selfHeal` but scope `prune` to stateless workloads, keep secrets out of git with External Secrets Operator, give each team an AppProject, and put Argo Rollouts in front of user-facing services.

We moved our first cluster to Argo CD in 2021 because `kubectl apply` from CI had stopped being honest: nobody could say what was running. As of October 2026 we run Argo CD 3.5, and what follows is what survived five years and the 3.0 upgrade.

## Repo structure: app-of-apps, then ApplicationSets

One giant repo with every manifest flat in a folder worked for six services and fell apart at forty. The pattern that scaled: a root Application pointing at a directory of other Applications ("app-of-apps"). Git owns the membership, and adding a service is a PR that adds a file. A leaf Application:

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

App-of-apps handles one cluster well. Once the same twelve apps run across dev, staging, and four regional prod clusters, hand-written Applications become copy-paste with a typo waiting to bite. ApplicationSets fix that: one generator, many Applications.

```yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: payments
  namespace: argocd
spec:
  generators:
    - matrix:
        generators:
          - clusters:
              selector:
                matchLabels:
                  env: prod
          - list:
              elements:
                - app: payments
                - app: ledger
  template:
    metadata:
      name: '{{.values.app}}-{{.name}}'
    spec:
      project: payments
      source:
        repoURL: https://github.com/acme/deploy.git
        targetRevision: main
        path: 'apps/{{.values.app}}/overlays/{{.metadata.labels.region}}'
      destination:
        server: '{{.server}}'
        namespace: payments
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
```

The matrix generator crosses registered clusters against a list of apps. Register a new prod cluster with the right labels and the apps show up. We run one ApplicationSet per team, because ownership follows the team. Note that Argo CD 3.0 always applies nested selectors, so recheck matrix generators when you upgrade from 2.x.

## Sync policies, and where to slam the brakes

`automated` with `selfHeal` and `prune` is the config people copy first and regret later. Our rule: automated sync everywhere, `selfHeal` everywhere, and `prune` on for stateless workloads but gated for anything with a PersistentVolumeClaim or a CRD other controllers depend on. `prune` deletes objects that left git. If someone refactors a directory and a StatefulSet's name changes, prune will happily delete the old one, PVC and all. We watched it happen in staging.

For the changes that scare you, like a database migration or a CRD version bump, use sync waves and hooks instead of reaching for manual sync. Annotate resources with `argocd.argoproj.io/sync-wave` so the migration Job runs and completes before the Deployment rolls, and a failing PreSync hook halts the sync with a clear status instead of a half-applied cluster. Manual sync as a safety gate mostly means someone forgets to click the button while drift piles up.

## Config and secrets

Kustomize overlays are our default for config: a `base/` plus per-environment `overlays/` carrying only what differs. Helm earns its place for third-party charts. The fuller argument is in [Kustomize overlays that scale](/blog/kustomize-overlays-that-scale).

Secrets have one rule: plaintext never goes in git. The two approaches we run:

- **External Secrets Operator** when a real secret manager (Vault, AWS Secrets Manager, GCP Secret Manager) is the source of truth. Git holds an `ExternalSecret` that references a key; the operator materializes the `Secret`. Our default, because rotation happens in one place.
- **Sealed Secrets** when you have no external manager. The controller holds the private key and decrypts in-cluster; rotation is more manual.

## Progressive delivery

Argo CD syncs manifests; it does not make a rollout gradual. Argo Rollouts (v1.10 as of October 2026) replaces the Deployment with a `Rollout` that supports canary and blue-green and queries a metrics provider to promote or abort. Our pattern: 10 percent canary, an AnalysisTemplate checking Prometheus error rate and p99 latency, automatic abort. It has blocked releases at 3am with nobody watching. The [Argo Rollouts progressive delivery](/blog/argo-rollouts-progressive-delivery) walkthrough has the analysis templates we actually run.

## Drift, RBAC, and knowing what's happening

**Drift detection** is Argo CD's quiet superpower. Every app is `Synced`, `OutOfSync`, or `Unknown`, and `OutOfSync` means live state diverged from git: someone ran `kubectl edit`, a mutating webhook added a sidecar. With `selfHeal` on, Argo CD reverts the drift within its reconciliation window; without it, the diff view shows exactly what changed. Since 3.0, Argo CD tracks resources by annotation instead of label by default, which stops false drift from tools that also set `app.kubernetes.io/instance`.

**RBAC and multi-tenancy** run through AppProjects. A Project scopes which repos an app can pull from, which clusters and namespaces it can deploy to, and which resource kinds are allowed. We give each team a Project, deny cluster-scoped resources by default, and grant them explicitly. Map SSO groups to Argo CD roles. In 3.x, `update` and `delete` on an Application no longer cascade to its managed resources, and the logs tab needs an explicit `logs, get` grant, so audit your RBAC policies on upgrade.

**Observability:** scrape the application controller into Prometheus and alert on apps stuck `OutOfSync` and on sync failures. Argo CD 3.0 removed `argocd_app_sync_status` and `argocd_app_health_status`; build alerts on the `sync_status` and `health_status` labels of `argocd_app_info` instead. A stuck app sits there looking fine in a list, so it needs an alert.

## Anti-patterns we stopped repeating

- **Deploying from the same repo as your app code.** App churn drags your cluster history with it.
- **Letting CI run `kubectl apply` "just for this one thing."** The exception erodes the model.
- **One enormous Application for a whole cluster.** A single failed resource can wedge the sync for everything.
- **Auto-sync with no progressive delivery on user-facing services.** Fast propagation of a bad change is an outage.

## Questions people ask

### What are the most important Argo CD best practices?

Separate the deploy repo from app code, generate Applications with ApplicationSets, enable automated sync with `selfHeal`, scope `prune` away from stateful resources, keep secrets out of git, and isolate teams with AppProjects. Those six cover most of the incidents we have seen.

### Should I enable prune and selfHeal in production?

Enable `selfHeal` everywhere; it reverts manual edits. Enable `prune` for stateless workloads, but gate it for anything with a PersistentVolumeClaim, because a renamed StatefulSet gets deleted along with its storage.

### What changed in Argo CD 3.0 that affects these practices?

Annotation-based resource tracking became the default, fine-grained RBAC stopped inheriting `update` and `delete` to managed resources, logs access needs explicit RBAC, and the standalone sync and health status metrics were folded into `argocd_app_info`. Read the 2.14 to 3.0 upgrade notes before upgrading.

## The call we'd make

Start with app-of-apps on one cluster, automated sync with self-heal on from day one, and prune scoped to stateless workloads. Move to ApplicationSets the moment you're copy-pasting Applications across environments, not before; at six services the matrix generator is overhead. Put secrets in External Secrets Operator, keep a deploy repo separate from app code, and give every team an AppProject with cluster-scoped resources denied. Add Argo Rollouts before you think you need it. The rest is discipline: if it's running in the cluster and it isn't in git, treat that as the bug.

## Sources

- [Argo CD releases on GitHub](https://github.com/argoproj/argo-cd/releases)
- [Argo CD docs: Upgrading from 2.14 to 3.0](https://argo-cd.readthedocs.io/en/stable/operator-manual/upgrading/2.14-3.0/)
- [Argo CD docs: Automated sync policy](https://argo-cd.readthedocs.io/en/stable/user-guide/auto_sync/)
- [Argo CD docs: ApplicationSet controller](https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/)

