En esta guía (56 elementos)
GitOps + ArgoCD
GitOps + ArgoCD Guidelines
MANDATORY FOR ALL AI AGENTS: Every Kubernetes-bound deployment in this platform MUST be GitOps-driven. The Git repository is the single source of truth for cluster state — no
kubectl applyfrom laptops, no manual Helm installs, no console-edited manifests. ArgoCD reconciles cluster → Git, not the other way. Any drift between cluster and Git is either auto-healed or flagged as a fault. CI builds artifacts (images, charts); GitOps deploys them by writing a new commit, not by calling the cluster API.
Project-agnostic: code samples use placeholder names (
platform-payment-service,platform-gitops), domain (example.com), namespace (platform), and registry (ghcr.io/example). Replace with your project’s actual values.
Reference implementation: ArgoCD
3.0.x(3.x is GA since 3.0, May 2025) + Kustomize5.4.x+ Sealed-Secrets0.27.x+ Argo Rollouts1.8.x. Helm3.16+for third-party charts only. ApplicationSet controller bundled with ArgoCD.
Companion guides:
- api-gateway.md — the gateway is deployed via ArgoCD too; its config repo is a GitOps subject.
- security.md — secrets management, RBAC, SSO/OIDC for the ArgoCD UI/API.
- obs-tracing.md — ArgoCD emits Prometheus metrics + OTEL spans for sync events.
- adr.md — GitOps tool choice, repo topology, promotion strategy, secrets strategy are ADR-worthy.
- arch-doc-spec.md §Deployment View — GitOps controllers are first-class deployment topology actors.
Companion commands:
/arch-threat— threat-model the GitOps control plane (ArgoCD is an attack surface)./arch-deps— audit chart + image dependencies referenced in the GitOps repo.
Table of Contents
- Principles
- CI/CD Boundary
- Repository Topology
- ArgoCD Architecture
- Application CRD
- Project CRD + RBAC
- Sync Policies
- Sync Waves + Hooks
- App-of-Apps Pattern
- ApplicationSet Pattern
- Multi-Environment with Kustomize
- Helm in GitOps
- Promotion Strategy
- Secrets Management
- Image Update Automation
- Progressive Delivery (Argo Rollouts)
- Drift Detection + Notifications
- Health Checks + Resource Customizations
- Multi-Cluster
- Disaster Recovery + Bootstrap
- Observability
- Anti-Patterns
- Pre-Merge Checklist
1. Principles
GitOps is defined by four properties (OpenGitOps v1.0.0). Every rule below derives from one of them.
- Declarative. All desired state is described declaratively (YAML manifests, Helm values, Kustomize overlays). No imperative scripts (
kubectl create,helm install) in the deploy path. - Versioned + immutable. Git is the source of truth; every change is a commit with author + diff + signature. Tags and SHAs are immutable; branches are pointers.
- Pulled automatically. A controller (ArgoCD) running in the cluster pulls state from Git on a loop. The cluster reconciles toward Git — not the other way around.
- Continuously reconciled. Drift between Git and cluster is detected and either auto-healed or alerted. Manual
kubectl editis reverted unless explicitly excluded.
Derived rules:
- No human writes to the cluster API for desired state. All changes go through Git PRs.
- CI never calls
kubectl apply. CI writes a commit to the GitOps repo; ArgoCD applies it. - Cluster credentials never leave the cluster. Developer laptops have read-only kubeconfig at best. Write access via ArgoCD UI/API only.
- Rollback =
git revert. Nothelm rollback, notkubectl rollout undo— those create drift. - Secrets are encrypted at rest in Git. Plaintext secrets in a GitOps repo is a security incident.
- Every environment maps to one or more Git refs. No “fix it live, sync to Git later.” If it’s not in Git, it doesn’t exist.
2. CI/CD Boundary
The hardest line to hold. CI and CD have separate concerns and separate repos.
| Concern | Owner | Output |
|---|---|---|
| Build image / chart / artifact | CI (GitHub Actions, GitLab CI, etc.) | Signed image in registry + chart in OCI repo |
| Run tests, lint, security scan | CI | Pass/fail signal + SBOM |
| Push image tag to registry | CI | ghcr.io/example/platform-payment-service:1.4.2 |
| Update GitOps repo with new tag | CI (write a commit) or Image Updater (poll registry) | A commit in platform-gitops |
| Apply manifest to cluster | CD (ArgoCD) — never CI | Reconciled cluster state |
| Sync status, health, rollback | CD | ArgoCD UI + metrics |
Hard rule: CI MUST NOT have cluster credentials. The only artifact CI produces that the cluster sees is a Git commit in the GitOps repo.
2.1 The “image promotion” handoff
CI produces a tagged image; it must record that tag somewhere ArgoCD reads. Two valid patterns:
A. CI writes the GitOps repo directly (push pattern):
# .github/workflows/release.yml (excerpt)- name: Update GitOps manifest run: | git clone https://${{ secrets.GITOPS_TOKEN }}@github.com/example/platform-gitops cd platform-gitops yq -i '.images[0].newTag = "${{ steps.meta.outputs.version }}"' \ envs/dev/payment-service/kustomization.yaml git commit -am "chore(dev): bump payment-service to ${{ steps.meta.outputs.version }}" git pushB. ArgoCD Image Updater polls the registry (pull pattern, see §15). CI does nothing extra; the updater writes the commit.
Pattern A is simpler and explicit (every deploy = a clear CI commit). Pattern B keeps CI dumber but adds a new controller to operate. Choose one per env tier — pattern A is fine for dev, B is convenient for many microservices.
3. Repository Topology
Two valid layouts. Pick one in an ADR and stick to it.
3.1 Split: app repos + one GitOps repo (recommended)
example/platform-payment-service ← app code, Dockerfile, Helm chart source (if any)example/platform-billing-service ← app code, ...example/platform-gitops ← all Kubernetes manifests, all environmentsWhy split. App developers stay in app repos; platform team owns deploy topology. PRs to platform-gitops are reviewed by SRE. CI from any app repo writes its tag bump as a PR to platform-gitops.
Layout of platform-gitops:
platform-gitops/├── README.md├── bootstrap/ # ArgoCD itself + cluster-wide controllers│ ├── argocd/│ │ ├── kustomization.yaml│ │ └── values.yaml│ ├── sealed-secrets/│ ├── argo-rollouts/│ └── ingress-nginx/├── apps/ # ApplicationSet + Application manifests│ ├── root-app.yaml # app-of-apps entry (§9)│ ├── platform-services.yaml # ApplicationSet (§10)│ └── infra-controllers.yaml├── base/ # Kustomize bases (env-neutral)│ ├── payment-service/│ │ ├── deployment.yaml│ │ ├── service.yaml│ │ ├── kustomization.yaml│ │ └── ...│ └── billing-service/├── envs/ # per-env overlays│ ├── dev/│ │ ├── payment-service/│ │ │ └── kustomization.yaml│ │ └── ...│ ├── staging/│ └── prod/└── projects/ # AppProject CRs (RBAC) (§6) ├── platform-dev.yaml ├── platform-staging.yaml └── platform-prod.yaml3.2 Mono: app + manifests in same repo
Only viable for very small platforms (≤ 3 services). Risks: app PRs collide with deploy PRs; CI must guard against feedback loops (a tag bump must NOT trigger another CI run).
3.3 Env branches vs env folders
Env folders (recommended). One repo, one branch (main), folders per env: envs/dev/, envs/staging/, envs/prod/. Promotion = a PR that copies a manifest fragment from envs/dev/ to envs/staging/. Easy to diff, easy to audit.
Env branches (anti-pattern in most cases). One branch per env. Tempting but: branches drift, merge conflicts are constant, git log per env is fragmented. Use only if your platform has hard separation rules (e.g., different teams own different envs and you want branch protection rules per env).
4. ArgoCD Architecture
ArgoCD runs in the cluster it manages (and optionally manages remote clusters too — see §19).
Components:
| Component | Role |
|---|---|
argocd-server |
API + Web UI. gRPC + REST. SSO via Dex or external OIDC. |
argocd-application-controller |
Reconciler. Watches Application CRs, diffs cluster vs Git, syncs. |
argocd-repo-server |
Stateless. Clones Git repos, renders Helm/Kustomize/Jsonnet to plain YAML. |
argocd-redis |
Cache for repo-server output + session state. Not durable. |
argocd-dex-server (optional) |
OIDC federation if your IdP isn’t directly compatible. |
argocd-notifications-controller |
Sends sync/health events to Slack/email/webhook (§17). |
argocd-applicationset-controller |
Generates Application CRs from generators (§10). |
argocd-image-updater (optional, §15) |
Polls registry, writes back to GitOps repo. |
Install ArgoCD via the official Helm chart or upstream Kustomize manifest. Pin a specific version. Track the upgrade per CVE in bootstrap/argocd/.
4.1 Bootstrap chicken-and-egg
ArgoCD manages itself, but cannot install itself. Bootstrap order:
- Cluster created (Terraform / eksctl / gke / kubeadm).
kubectl applythe ArgoCD manifest once, manually from a runbook (this is the only sanctioned manual apply).- Apply the root app (
apps/root-app.yaml) — an Application pointing atbootstrap/argocd/itself. - From now on, ArgoCD upgrades itself via Git commits. Manual step never repeats unless the cluster is destroyed.
The bootstrap script (scripts/bootstrap.sh) lives in platform-gitops and is documented in the README.
5. Application CRD
The unit of deployment in ArgoCD.
apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: payment-service-dev namespace: argocd finalizers: - resources-finalizer.argocd.argoproj.io # delete child resources on app deletespec: project: platform-dev source: repoURL: https://github.com/example/platform-gitops targetRevision: main path: envs/dev/payment-service destination: server: https://kubernetes.default.svc namespace: platform syncPolicy: automated: prune: true selfHeal: true allowEmpty: false syncOptions: - CreateNamespace=true - PrunePropagationPolicy=foreground - ApplyOutOfSyncOnly=true retry: limit: 5 backoff: duration: 5s factor: 2 maxDuration: 3m revisionHistoryLimit: 10Critical fields:
project: AppProject reference for RBAC (§6). Neverdefaultin production.targetRevision: branch (mutable, e.g.,main) for dev; tag or commit SHA (immutable) for prod. Mutable refs in prod = audit failure.syncPolicy.automated.prune: true: required for true GitOps. Without prune, deleted manifests stay alive in the cluster.syncPolicy.automated.selfHeal: true: reverts manualkubectl editdrift. Disable only for resources legitimately mutated outside Git (rare).finalizers: ensures children are GC’d on app delete. Forgetting this leaves orphans.
5.1 Multi-source applications
ArgoCD 2.6+ supports multiple sources (one Helm chart + one values repo). Useful when you deploy a third-party chart you don’t fork:
spec: sources: - repoURL: https://prometheus-community.github.io/helm-charts chart: kube-prometheus-stack targetRevision: 65.5.0 helm: valueFiles: - $values/envs/prod/kube-prometheus-stack/values.yaml - repoURL: https://github.com/example/platform-gitops targetRevision: main ref: valuesPin chart targetRevision to an exact version. Never * or latest.
6. Project CRD + RBAC
AppProjects scope what an Application can deploy and who can manage it. Defense-in-depth against a compromised app developer or a mis-routed PR.
apiVersion: argoproj.io/v1alpha1kind: AppProjectmetadata: name: platform-prod namespace: argocdspec: description: Production services sourceRepos: - https://github.com/example/platform-gitops destinations: - server: https://kubernetes.default.svc namespace: platform - server: https://kubernetes.default.svc namespace: platform-jobs clusterResourceWhitelist: - group: '' kind: Namespace namespaceResourceWhitelist: - group: '*' kind: '*' namespaceResourceBlacklist: - group: '' kind: ResourceQuota - group: '' kind: LimitRange roles: - name: developer description: Read + manual sync of dev/staging only — NOT prod policies: - p, proj:platform-prod:developer, applications, get, platform-prod/*, allow - p, proj:platform-prod:developer, applications, sync, platform-prod/*, deny groups: - platform-dev-engineers - name: sre description: Full control policies: - p, proj:platform-prod:sre, applications, *, platform-prod/*, allow groups: - platform-sre signatureKeys: - keyID: ABCDEF0123456789 orphanedResources: warn: truesourceReposrestricts which Git repos this project may reference. Prevents an Application inplatform-prodfrom secretly pulling from a fork.destinationsrestricts which clusters + namespaces. Combined withclusterResourceWhitelist, prevents privilege escalation via cluster-scoped resources.signatureKeysrequires commits in the source repo to be GPG-signed by an allow-listed key. Strongly recommended for prod.orphanedResources.warn: trueflags resources in the target namespace not managed by any Application — surfaces drift.
SSO + group mapping is configured in argocd-rbac-cm ConfigMap. Map IdP groups (Okta/Keycloak/AAD) to project roles. Never use the built-in admin account beyond bootstrap.
7. Sync Policies
Three modes:
| Mode | Setup | When |
|---|---|---|
| Manual | syncPolicy: {} (no automated block) |
Production-critical apps where a human approval gate is mandated. |
| Auto, no self-heal | automated: { prune: true, selfHeal: false } |
Some legitimate live edits expected (e.g., HPA-managed replicas). |
| Auto + self-heal | automated: { prune: true, selfHeal: true } |
Default for everything. True GitOps. |
7.1 PruneLast + IgnoreDifferences
Some resources have fields the cluster mutates (e.g., Deployment.spec.replicas when HPA is active). Without telling ArgoCD, every reconcile will fight the HPA:
spec: ignoreDifferences: - group: apps kind: Deployment jsonPointers: - /spec/replicasUse sparingly. Each ignoreDifferences entry is a known divergence between Git and cluster — document why.
7.2 Sync options that matter
| Option | Effect |
|---|---|
CreateNamespace=true |
Auto-create the target namespace if missing. |
PrunePropagationPolicy=foreground |
Wait for child cleanup. Default is background. |
ApplyOutOfSyncOnly=true |
Skip resources already in sync. Faster reconcile. |
ServerSideApply=true |
Use SSA. Required for some operator-owned resources. |
Validate=false |
Skip kubectl --dry-run validation. Use only when CRD is installed by the same sync. |
RespectIgnoreDifferences=true |
Honor ignoreDifferences even during sync, not just diff. |
8. Sync Waves + Hooks
Order matters when one manifest depends on another (CRD before CR, namespace before deployment, secret before pod).
8.1 Sync waves
Annotate resources with argocd.argoproj.io/sync-wave. Lower waves apply first. Default wave is 0. Negative is allowed.
metadata: annotations: argocd.argoproj.io/sync-wave: "-1" # CRDs firstTypical pattern:
-2Namespaces, ResourceQuotas-1CRDs, ClusterRoles0ConfigMaps, Secrets, ServiceAccounts (default)1Services, Deployments, StatefulSets2Ingress, HPAs3Smoke tests (Jobs as PostSync hooks)
8.2 Hooks
Run a Job at a specific phase. Hook = a manifest annotated with argocd.argoproj.io/hook:
| Phase | Runs |
|---|---|
PreSync |
Before the sync (e.g., DB migration) |
Sync |
Mid-sync, before applying main resources |
PostSync |
After sync + all resources Healthy (e.g., smoke test) |
SyncFail |
If sync fails (alerting / cleanup) |
Delete policies for hooks: HookSucceeded, HookFailed, BeforeHookCreation. Default leaves the Job around; use HookSucceeded to clean up after success.
apiVersion: batch/v1kind: Jobmetadata: name: payment-db-migrate annotations: argocd.argoproj.io/hook: PreSync argocd.argoproj.io/hook-delete-policy: BeforeHookCreationspec: template: spec: restartPolicy: Never containers: - name: liquibase image: ghcr.io/example/platform-payment-service:1.4.2 args: ["liquibase", "update"]Liquibase + GitOps pairing. Liquibase runs as a PreSync hook (or as an initContainer — debatable). The image of the hook MUST match the image of the deployment about to roll out. Use the same targetRevision for both. See liquibase.md for migration discipline.
9. App-of-Apps Pattern
One root Application points at a folder of other Application manifests. Bootstrap installs the root; the root brings up everything else.
apps/├── root-app.yaml # ← the only app you apply manually└── children/ ├── platform-services.yaml # ApplicationSet ├── infra-controllers.yaml └── monitoring.yamlapiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: root namespace: argocdspec: project: platform-bootstrap source: repoURL: https://github.com/example/platform-gitops targetRevision: main path: apps/children directory: recurse: true destination: server: https://kubernetes.default.svc namespace: argocd syncPolicy: automated: prune: true selfHeal: trueTrade-off vs ApplicationSet: app-of-apps is simpler to reason about and easier for cross-team review. ApplicationSet is more powerful for fan-out (one template → N apps). Use both: app-of-apps at the root, ApplicationSets for service families.
10. ApplicationSet Pattern
Templated, generator-driven Application creation. Eliminates copy-paste of N near-identical Application manifests.
10.1 Generators
| Generator | Use |
|---|---|
| List | Hard-coded list of envs/services. Simplest. |
| Git directory | One App per folder in Git. Adding a folder = new App. |
| Git file | One App per YAML file matched by glob. |
| Cluster | One App per registered cluster. Multi-cluster fan-out. |
| Matrix | Cartesian product (e.g., envs × services). Powerful and dangerous. |
| Merge | Outer join of generators. |
| SCM Provider | One App per GitHub/GitLab repo matching criteria. |
| Pull Request | Per-PR preview environment. |
10.2 Per-env per-service fan-out
apiVersion: argoproj.io/v1alpha1kind: ApplicationSetmetadata: name: platform-services namespace: argocdspec: generators: - matrix: generators: - list: elements: - env: dev cluster: https://kubernetes.default.svc namespace: platform - env: staging cluster: https://kubernetes.default.svc namespace: platform-staging - env: prod cluster: https://prod-cluster.example.com namespace: platform - git: repoURL: https://github.com/example/platform-gitops revision: main directories: - path: base/* template: metadata: name: '{{path.basename}}-{{env}}' spec: project: 'platform-{{env}}' source: repoURL: https://github.com/example/platform-gitops targetRevision: main path: 'envs/{{env}}/{{path.basename}}' destination: server: '{{cluster}}' namespace: '{{namespace}}' syncPolicy: automated: prune: true selfHeal: trueAdding a new service = drop a folder in base/ and envs/<env>/; the ApplicationSet generates 3 Application CRs automatically.
10.3 Preview environments via PR generator
generators: - pullRequest: github: owner: example repo: platform-payment-service labels: ["preview"] requeueAfterSeconds: 60template: metadata: name: 'preview-payment-{{number}}' spec: source: repoURL: https://github.com/example/platform-payment-service targetRevision: '{{head_sha}}' path: deploy/preview destination: namespace: 'preview-{{number}}'One ephemeral env per PR, gone when the PR closes. Watch resource quotas.
11. Multi-Environment with Kustomize
Kustomize is the default rendering engine for first-party manifests. Helm only for third-party charts (see §12).
11.1 Layout
base/payment-service/├── deployment.yaml├── service.yaml├── configmap.yaml├── hpa.yaml└── kustomization.yamlapiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources: - deployment.yaml - service.yaml - configmap.yaml - hpa.yamlcommonLabels: app.kubernetes.io/name: payment-service app.kubernetes.io/part-of: platformapiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationnamespace: platformresources: - ../../../base/payment-servicepatches: - path: deployment-patch.yaml target: kind: Deployment name: payment-serviceimages: - name: ghcr.io/example/platform-payment-service newTag: 1.4.2configMapGenerator: - name: payment-service-config behavior: merge literals: - SPRING_PROFILES_ACTIVE=prod - LOG_LEVEL=INFOreplicas: - name: payment-service count: 611.2 Rules
- Base is environment-neutral. No env-specific values. No replica counts, no image tags pinned in base (use a placeholder).
- Image tag is owned by overlay. Never in base. CI / Image Updater writes the overlay file.
- No
vars:between envs. Each overlay is fully self-contained. commonLabelsonly in base unless an env truly needs an extra label.- No symbolic links across overlays — ArgoCD repo-server may not follow them in all storage backends.
12. Helm in GitOps
Helm is fine for third-party charts you don’t author (Prometheus, cert-manager, ingress-nginx, Postgres operator). Avoid Helm for first-party services — Kustomize handles the same patterns with less ceremony.
12.1 Pin chart versions
spec: source: repoURL: https://prometheus-community.github.io/helm-charts chart: kube-prometheus-stack targetRevision: 65.5.0 # exact version, never '*' helm: releaseName: monitoring valueFiles: - $values/envs/prod/kube-prometheus-stack/values.yaml parameters: - name: prometheus.prometheusSpec.retention value: 30d12.2 Render vs install
ArgoCD calls helm template at sync time. It does NOT write a Helm release secret. If your chart has post-install hooks that depend on a Helm release object, you have to either move them to ArgoCD hooks (§8.2) or use the Replace=true sync option.
12.3 OCI charts
source: repoURL: oci://ghcr.io/example/charts chart: platform-shared targetRevision: 2.1.0OCI registries supported since ArgoCD 2.7. Authenticate via repository secret with the OCI scheme.
13. Promotion Strategy
How does a tag tested in dev reach prod?
13.1 Folder copy (recommended)
PR copies (or bumps) the image tag in envs/staging/<svc>/kustomization.yaml from the value in envs/dev/<svc>/kustomization.yaml. Same for staging → prod.
Pros: full audit (one PR = one promotion); easy to diff; reviewable.
Cons: a few seconds of manual work per promotion. (Often the right cost.)
13.2 Tag-based promotion via Image Updater
Image Updater (§15) sees a new tag matching prod-* and writes the bump. CI tags an image prod-1.4.2 only after staging soak passes.
Pros: zero-touch promotion.
Cons: implicit; auditors must read updater rules to understand why a deploy happened.
13.3 PR automation (Kargo, argo-promotion)
External controller opens promotion PRs automatically when upstream env stays healthy for N minutes. Use only when you have ≥ 4 environments and manual PRs become noise. Adds a new tool to the operate list.
13.4 Never:
- Sync a manifest with a
latesttag in prod. - Re-tag an image in the registry (e.g., move
prod-stableto a new digest). Breaks ArgoCD’s diff because the manifest didn’t change but the runtime did.
14. Secrets Management
Plaintext secrets MUST NOT live in Git. Three viable patterns; pick one per project in an ADR.
14.1 Sealed Secrets (Bitnami)
Cluster has a controller with a private key. Developers encrypt secrets with the public key into SealedSecret CRs, commit them, and the controller decrypts to a real Secret in the cluster.
kubeseal --controller-namespace sealed-secrets --controller-name sealed-secrets \ --format yaml < secret.yaml > sealed-secret.yamlPros: simple, self-contained, cluster-only trust boundary. Cons: private key is the disaster recovery artifact — back it up offline. If lost, every sealed secret in Git is unreadable.
14.2 External Secrets Operator (ESO)
Cluster has a controller that pulls from an external store (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Azure Key Vault) and projects them as Kubernetes Secrets. Git holds only a reference (ExternalSecret CR).
apiVersion: external-secrets.io/v1kind: ExternalSecretmetadata: name: payment-service-dbspec: refreshInterval: 1h secretStoreRef: name: aws-secrets-manager kind: ClusterSecretStore target: name: payment-service-db data: - secretKey: PASSWORD remoteRef: key: platform/prod/payment-service/db property: passwordPros: best when org already has a secrets store; clear separation of duties; rotation is upstream. Cons: adds an external dependency on the secrets store; cluster needs IAM access.
14.3 SOPS + age/PGP
Files in Git are encrypted with SOPS using age or PGP keys. ArgoCD has no native SOPS support — you need a plugin (argocd-vault-plugin, helm-secrets, or a custom CMP).
Pros: works in plain Git, no extra controller. Cons: adds a config management plugin to the repo-server; key management still required.
14.4 Hard rules
- Never commit a plaintext
SecretCR, even in a private repo. Defense-in-depth. - Rotate the sealing/decryption key on a schedule and on staff offboarding.
- Audit access to the secrets store. ESO config is fine in Git; the IAM role behind it isn’t (back it with Terraform).
- CI/CD systems get short-lived OIDC tokens, not long-lived secret store credentials.
See security.md §Secrets Management for the threat model.
15. Image Update Automation
ArgoCD Image Updater watches container registries and writes tag bumps back to the GitOps repo.
# Annotations on the Applicationmetadata: annotations: argocd-image-updater.argoproj.io/image-list: payment=ghcr.io/example/platform-payment-service argocd-image-updater.argoproj.io/payment.update-strategy: semver argocd-image-updater.argoproj.io/payment.allow-tags: regexp:^1\.[0-9]+\.[0-9]+$ argocd-image-updater.argoproj.io/write-back-method: git argocd-image-updater.argoproj.io/git-branch: mainUpdate strategies:
| Strategy | Effect |
|---|---|
semver |
Highest semver matching constraint |
latest |
Most recently pushed tag (dangerous — avoid in prod) |
digest |
New digest on the same tag (good for mutable staging tag → immutable digest in Git) |
name |
Alphabetical tag ordering (custom schemes) |
Write-back methods:
argocd— directly patches the Application (transient, not GitOps; not recommended).git— commits to the GitOps repo (true GitOps; recommended).
The updater needs a Git write credential. Store it as a Secret in argocd-image-updater namespace. Use a deploy key with write access scoped to the GitOps repo only, not a personal token with org-wide access.
Limit scope. Updater should be enabled per Application that opts in via annotation, not cluster-wide. Don’t let an updater bug auto-promote a half-baked image to prod.
16. Progressive Delivery (Argo Rollouts)
Deployment does a rolling update. For canary, blue/green, or analysis-gated rollouts use Argo Rollouts — a CRD that replaces Deployment.
apiVersion: argoproj.io/v1alpha1kind: Rolloutmetadata: name: payment-servicespec: replicas: 6 selector: matchLabels: app.kubernetes.io/name: payment-service template: metadata: labels: app.kubernetes.io/name: payment-service spec: securityContext: runAsNonRoot: true runAsUser: 10001 seccompProfile: type: RuntimeDefault containers: - name: payment # Prefer a distroless (or non-root) base image so the container has no shell/root user. image: ghcr.io/example/platform-payment-service:1.4.2 ports: - containerPort: 8080 securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: - ALL strategy: canary: canaryService: payment-service-canary stableService: payment-service-stable trafficRouting: nginx: stableIngress: payment-service steps: - setWeight: 10 - pause: { duration: 5m } - analysis: templates: - templateName: success-rate args: - name: service-name value: payment-service-canary - setWeight: 30 - pause: { duration: 10m } - setWeight: 60 - pause: { duration: 10m } - setWeight: 100apiVersion: argoproj.io/v1alpha1kind: AnalysisTemplatemetadata: name: success-ratespec: args: - name: service-name metrics: - name: success-rate interval: 1m successCondition: result[0] >= 0.99 failureLimit: 3 provider: prometheus: address: http://prometheus.monitoring:9090 query: | sum(rate(http_server_requests_seconds_count{ service="{{args.service-name}}", status!~"5.." }[2m])) / sum(rate(http_server_requests_seconds_count{ service="{{args.service-name}}" }[2m]))- Analysis runs Prometheus queries (or NewRelic / Datadog / Wavefront) and aborts the rollout on failure.
- Traffic routing integrates with NGINX, Istio, Linkerd, AWS ALB, SMI, Traefik.
- Blue/green strategy is one alternative; canary is more common for stateless HTTP services.
ArgoCD treats Rollout as a healthy first-class resource via the bundled Lua health check. No special config beyond installing the Rollouts controller via GitOps.
17. Drift Detection + Notifications
17.1 Detection
ArgoCD reconciler diffs every Application every 3 minutes (configurable). States:
| State | Meaning |
|---|---|
Synced |
Cluster matches Git. |
OutOfSync |
Cluster diverges. If selfHeal: true, it re-applies. |
Healthy |
All children pass health checks. |
Degraded |
At least one child unhealthy. |
Missing |
Manifest in Git but not in cluster (sync pending). |
Unknown |
Reconcile failed (Git unreachable, RBAC broken). |
17.2 Notifications
apiVersion: v1kind: ConfigMapmetadata: name: argocd-notifications-cm namespace: argocddata: service.slack: | token: $slack-token template.app-degraded: | message: | :rotating_light: {{.app.metadata.name}} is *{{.app.status.health.status}}* Project: {{.app.spec.project}} Repo: {{.app.spec.source.repoURL}} Revision: {{.app.status.sync.revision}} trigger.on-degraded: | - description: Application degraded send: [app-degraded] when: app.status.health.status == 'Degraded' subscriptions: | - recipients: [slack:platform-alerts] triggers: [on-degraded, on-sync-failed]Per-app subscription via annotation:
metadata: annotations: notifications.argoproj.io/subscribe.on-degraded.slack: platform-payment-alertsBake critical triggers (sync-failed, degraded, deleted) into project-default subscriptions. Per-team channels via annotation.
18. Health Checks + Resource Customizations
Default health checks exist for Deployment, StatefulSet, DaemonSet, Pod, Service, Ingress, Job, PVC, ReplicaSet, Application, Rollout. For custom CRDs (operators), ArgoCD shows them as Healthy immediately — wrong.
Add a Lua health check:
data: resource.customizations.health.cert-manager.io_Certificate: | hs = {} if obj.status ~= nil then if obj.status.conditions ~= nil then for i, condition in ipairs(obj.status.conditions) do if condition.type == "Ready" and condition.status == "False" then hs.status = "Degraded" hs.message = condition.message return hs end if condition.type == "Ready" and condition.status == "True" then hs.status = "Healthy" hs.message = condition.message return hs end end end end hs.status = "Progressing" hs.message = "Waiting for certificate" return hsTest Lua snippets with the argocd admin CLI before committing.
19. Multi-Cluster
One ArgoCD installation, N target clusters. Register each cluster:
argocd cluster add my-prod-context --name prod-eu-west-1 --grpc-webCreates a Secret in the argocd namespace with the kubeconfig. The Application’s destination.server field references the cluster API URL.
19.1 Hub-and-spoke
| Decision | Recommendation |
|---|---|
| Run ArgoCD where? | Dedicated management cluster. Avoid running ArgoCD in a workload cluster it also manages — blast radius issue if the workload cluster goes down. |
| One ArgoCD per region? | If clusters span regions, yes — keep ArgoCD close to the cluster API for sync latency and to limit cross-region blast radius. |
| Cluster credentials? | Use ClusterSecretStore (ESO) or IRSA / Workload Identity instead of long-lived kubeconfigs. |
19.2 ApplicationSet cluster generator
generators: - clusters: selector: matchLabels: env: prodArgoCD labels each registered cluster Secret; the generator fans out one Application per matching cluster. Add a cluster → it appears automatically.
20. Disaster Recovery + Bootstrap
ArgoCD is recoverable from Git plus a small set of inputs:
| Artifact | Where it lives | Recovery |
|---|---|---|
| Cluster manifests | GitOps repo | git clone |
| ArgoCD itself | GitOps repo (bootstrap/argocd/) |
kubectl apply -k bootstrap/argocd |
| AppProjects, ApplicationSets, Applications | GitOps repo (apps/, projects/) |
Root app re-applies all |
| Sealed Secret private key | Offline backup (vault, encrypted USB, K8s backup) | Restore before applying SealedSecrets |
| External Secrets store IAM role | Terraform | terraform apply |
| ArgoCD UI admin password | Bootstrap-only; SSO afterwards | Reset via argocd-secret if needed |
20.1 DR runbook outline
# 1. Provision clusterterraform apply -target=module.cluster
# 2. Bootstrap ArgoCDkubectl apply -k bootstrap/argocd
# 3. Restore Sealed Secret key (if Sealed Secrets used)kubectl apply -f /secure-backup/sealed-secrets-key.yaml
# 4. Wait for ArgoCD CRDskubectl wait --for=condition=Established crd/applications.argoproj.io --timeout=300s
# 5. Apply root app — everything else reconcileskubectl apply -f apps/root-app.yamlTest this runbook on a fresh cluster at least quarterly. A DR plan that has never been executed is a hypothesis, not a plan.
21. Observability
ArgoCD exposes Prometheus metrics on every controller. Scrape and alert on:
| Metric | Alert |
|---|---|
argocd_app_info{health_status="Degraded"} |
Any app degraded > 10 min |
argocd_app_sync_total{phase="Failed"} |
Sync failures rate > 0 over 15 min |
argocd_app_reconcile_count |
Reconcile rate drops to 0 (controller stuck) |
argocd_git_request_duration_seconds |
p95 > 5s (Git provider issue) |
argocd_redis_request_duration_seconds |
p95 > 1s (cache backend issue) |
argocd_cluster_api_resource_objects |
Per cluster, sudden drop (cluster connectivity loss) |
Tracing: ArgoCD 2.13+ emits OTEL spans for repo-server rendering, controller reconcile, and API calls. Wire to the same OTEL collector as the application services. See obs-tracing.md.
Logs: JSON to stdout. Ship to the same log pipeline as workloads. Field trace_id correlates a sync event with the downstream service rollout.
22. Anti-Patterns
kubectl applyfrom a laptop or CI to a managed cluster. Defeats GitOps. CI pushes to Git only.helm install/helm upgradeoutside ArgoCD. Same as above — creates state ArgoCD doesn’t track.- Mutable image tags in prod (
:latest,:main,:stable). Disables rollback viagit revert(the tag still points to a newer digest). - Plaintext Secret in Git, even in a private repo. Always sealed, ESO-referenced, or SOPS-encrypted.
selfHeal: falseon first-party services without an explicit reason. Defeats reconciliation.- Disabling prune because “I’m scared of deletions.” Then GitOps doesn’t actually mirror Git — you have an additive system, not a sync.
- Per-env branches without a process (
dev,staging,mainbranches). Drift between branches is inevitable; promotion becomes merge-conflict resolution. - One giant
Applicationfor “everything in this namespace.” Slow diffs, all-or-nothing syncs, painful rollback. Split per service or per chart. - No AppProject (everything in
default). Any compromised Application can deploy anywhere. - No
finalizerson Applications. Deleting an Application leaves orphan resources. - Manual
Replace=trueas the default sync option. Breaks SSA-managed resources and operator semantics. Use only for resources that genuinely cannot be patched. - Image Updater with org-wide PAT. Use a deploy key scoped to the GitOps repo only.
- CI doing
kubectl rollout undoas the rollback mechanism. Rollback isgit revert→ ArgoCD reapplies prior state. - Skipping
ignoreDifferencesfor HPA-managedspec.replicas. Reconciler fights HPA forever; CPU burns; alerts page on phantom drift. - No sealing-key backup for Sealed Secrets. One controller pod loss + no backup = every encrypted secret in Git is rubble.
- Promotion via re-tagging the same image in the registry. Manifest didn’t change → ArgoCD sees no diff → no sync → confusion.
- ArgoCD running inside the workload cluster it manages, with no DR plan. Cluster outage = no recovery path.
- App-of-apps where the root has
selfHeal: false. A drifted child can’t be auto-recovered. Root must self-heal. - Sync waves abused for “deploy order” when waves are for resource-kind order. If service B must come up after service A is
Healthy, split into separate Applications and use a notification trigger or Argo Workflows — not waves. - Webhook from Git to ArgoCD over public internet without verification. Use the shared secret + IP allowlist. Otherwise an attacker can force-sync.
23. Pre-Merge Checklist
For any PR to the GitOps repo:
- Target file path matches the env tier the PR claims (
envs/dev/...vsenvs/prod/...). - Image tag is immutable (SHA digest or semver tag), not a branch name or
latest. - No plaintext secrets, tokens, or private keys in the diff (
grep -E 'BEGIN .* PRIVATE KEY|sk-[a-z]|ghp_|AKIA'). - No new
Applicationreferences a repo outside the project’ssourceReposallow-list. - No new
Applicationdeploys to a destination outside the project’sdestinationsallow-list. -
automated.prune: trueandselfHeal: trueunless an in-repo comment explains the exception. -
finalizers: [resources-finalizer.argocd.argoproj.io]present on Application. - Sync waves only used for resource-kind order, not service order.
- If adding a CRD, the corresponding CR uses sync wave > the CRD’s wave.
- If adding a hook,
hook-delete-policyis set (no orphan Jobs). - Pod/container security context hardened: non-root containers +
readOnlyRootFilesystem+ drop ALL caps (runAsNonRoot,allowPrivilegeEscalation: false,seccompProfile: RuntimeDefault). - If adding
ignoreDifferences, the comment explains which controller is mutating the field. - Helm
targetRevisionis pinned to an exact version (no*, nolatest). - CHANGELOG / commit body explains what is being deployed and why (link to upstream PR or issue).
- Promotion to prod has at least one CODEOWNER (SRE) approval.
- No simultaneous bump of multiple services in the same PR (one concern per commit; easier rollback).
- For prod,
targetRevisionis a tag or SHA — never a branch. - For prod, signed commits are required if AppProject has
signatureKeysconfigured.
For any code change in an app repo that triggers a deploy:
- CI does NOT call
kubectl,helm install, or any cluster API. - CI’s only deploy-time output is a commit/PR to the GitOps repo.
- If a DB migration is required, the corresponding Liquibase changeset is committed and a PreSync hook is configured (§8.2).
- Health check endpoints (
/actuator/health/liveness,/readiness) return correct status from the new image — ArgoCD reads them. - Image is signed (cosign / Notary). AppProject can enforce signature verification with
kyvernoorpolicy-controlleras a sync hook.
References
- OpenGitOps principles: https://opengitops.dev/
- ArgoCD docs: https://argo-cd.readthedocs.io/
- ApplicationSet generators: https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/Generators/
- Argo Rollouts: https://argo-rollouts.readthedocs.io/
- Sealed Secrets: https://sealed-secrets.netlify.app/
- External Secrets Operator: https://external-secrets.io/
- Kustomize: https://kustomize.io/