Skip to content
My Guidelines
Guides
In this guide (56 items)

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 apply from 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) + Kustomize 5.4.x + Sealed-Secrets 0.27.x + Argo Rollouts 1.8.x. Helm 3.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

  1. Principles
  2. CI/CD Boundary
  3. Repository Topology
  4. ArgoCD Architecture
  5. Application CRD
  6. Project CRD + RBAC
  7. Sync Policies
  8. Sync Waves + Hooks
  9. App-of-Apps Pattern
  10. ApplicationSet Pattern
  11. Multi-Environment with Kustomize
  12. Helm in GitOps
  13. Promotion Strategy
  14. Secrets Management
  15. Image Update Automation
  16. Progressive Delivery (Argo Rollouts)
  17. Drift Detection + Notifications
  18. Health Checks + Resource Customizations
  19. Multi-Cluster
  20. Disaster Recovery + Bootstrap
  21. Observability
  22. Anti-Patterns
  23. Pre-Merge Checklist

1. Principles

GitOps is defined by four properties (OpenGitOps v1.0.0). Every rule below derives from one of them.

  1. Declarative. All desired state is described declaratively (YAML manifests, Helm values, Kustomize overlays). No imperative scripts (kubectl create, helm install) in the deploy path.
  2. 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.
  3. 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.
  4. Continuously reconciled. Drift between Git and cluster is detected and either auto-healed or alerted. Manual kubectl edit is 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. Not helm rollback, not kubectl 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 push

B. 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.

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 environments

Why 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.yaml

3.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:

  1. Cluster created (Terraform / eksctl / gke / kubeadm).
  2. kubectl apply the ArgoCD manifest once, manually from a runbook (this is the only sanctioned manual apply).
  3. Apply the root app (apps/root-app.yaml) — an Application pointing at bootstrap/argocd/ itself.
  4. 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.

apps/payment-service-dev.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: payment-service-dev
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io # delete child resources on app delete
spec:
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: 10

Critical fields:

  • project: AppProject reference for RBAC (§6). Never default in 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 manual kubectl edit drift. 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: values

Pin 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.

projects/platform-prod.yaml
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: platform-prod
namespace: argocd
spec:
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: true
  • sourceRepos restricts which Git repos this project may reference. Prevents an Application in platform-prod from secretly pulling from a fork.
  • destinations restricts which clusters + namespaces. Combined with clusterResourceWhitelist, prevents privilege escalation via cluster-scoped resources.
  • signatureKeys requires commits in the source repo to be GPG-signed by an allow-listed key. Strongly recommended for prod.
  • orphanedResources.warn: true flags 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/replicas

Use 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 first

Typical pattern:

  • -2 Namespaces, ResourceQuotas
  • -1 CRDs, ClusterRoles
  • 0 ConfigMaps, Secrets, ServiceAccounts (default)
  • 1 Services, Deployments, StatefulSets
  • 2 Ingress, HPAs
  • 3 Smoke 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/v1
kind: Job
metadata:
name: payment-db-migrate
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
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.yaml
apps/root-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root
namespace: argocd
spec:
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: true

Trade-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/v1alpha1
kind: ApplicationSet
metadata:
name: platform-services
namespace: argocd
spec:
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: true

Adding 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: 60
template:
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.yaml
base/payment-service/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- configmap.yaml
- hpa.yaml
commonLabels:
app.kubernetes.io/name: payment-service
app.kubernetes.io/part-of: platform
envs/prod/payment-service/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: platform
resources:
- ../../../base/payment-service
patches:
- path: deployment-patch.yaml
target:
kind: Deployment
name: payment-service
images:
- name: ghcr.io/example/platform-payment-service
newTag: 1.4.2
configMapGenerator:
- name: payment-service-config
behavior: merge
literals:
- SPRING_PROFILES_ACTIVE=prod
- LOG_LEVEL=INFO
replicas:
- name: payment-service
count: 6

11.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.
  • commonLabels only 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: 30d

12.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.0

OCI 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?

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 latest tag in prod.
  • Re-tag an image in the registry (e.g., move prod-stable to 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.

Terminal window
kubeseal --controller-namespace sealed-secrets --controller-name sealed-secrets \
--format yaml < secret.yaml > sealed-secret.yaml

Pros: 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/v1
kind: ExternalSecret
metadata:
name: payment-service-db
spec:
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: password

Pros: 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 Secret CR, 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 Application
metadata:
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: main

Update 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/v1alpha1
kind: Rollout
metadata:
name: payment-service
spec:
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: 100
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: success-rate
spec:
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

bootstrap/argocd-notifications/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-notifications-cm
namespace: argocd
data:
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-alerts

Bake 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:

bootstrap/argocd/argocd-cm.yaml
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 hs

Test Lua snippets with the argocd admin CLI before committing.


19. Multi-Cluster

One ArgoCD installation, N target clusters. Register each cluster:

Terminal window
argocd cluster add my-prod-context --name prod-eu-west-1 --grpc-web

Creates 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: prod

ArgoCD 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

Terminal window
# 1. Provision cluster
terraform apply -target=module.cluster
# 2. Bootstrap ArgoCD
kubectl 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 CRDs
kubectl wait --for=condition=Established crd/applications.argoproj.io --timeout=300s
# 5. Apply root app — everything else reconciles
kubectl apply -f apps/root-app.yaml

Test 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

  1. kubectl apply from a laptop or CI to a managed cluster. Defeats GitOps. CI pushes to Git only.
  2. helm install / helm upgrade outside ArgoCD. Same as above — creates state ArgoCD doesn’t track.
  3. Mutable image tags in prod (:latest, :main, :stable). Disables rollback via git revert (the tag still points to a newer digest).
  4. Plaintext Secret in Git, even in a private repo. Always sealed, ESO-referenced, or SOPS-encrypted.
  5. selfHeal: false on first-party services without an explicit reason. Defeats reconciliation.
  6. Disabling prune because “I’m scared of deletions.” Then GitOps doesn’t actually mirror Git — you have an additive system, not a sync.
  7. Per-env branches without a process (dev, staging, main branches). Drift between branches is inevitable; promotion becomes merge-conflict resolution.
  8. One giant Application for “everything in this namespace.” Slow diffs, all-or-nothing syncs, painful rollback. Split per service or per chart.
  9. No AppProject (everything in default). Any compromised Application can deploy anywhere.
  10. No finalizers on Applications. Deleting an Application leaves orphan resources.
  11. Manual Replace=true as the default sync option. Breaks SSA-managed resources and operator semantics. Use only for resources that genuinely cannot be patched.
  12. Image Updater with org-wide PAT. Use a deploy key scoped to the GitOps repo only.
  13. CI doing kubectl rollout undo as the rollback mechanism. Rollback is git revert → ArgoCD reapplies prior state.
  14. Skipping ignoreDifferences for HPA-managed spec.replicas. Reconciler fights HPA forever; CPU burns; alerts page on phantom drift.
  15. No sealing-key backup for Sealed Secrets. One controller pod loss + no backup = every encrypted secret in Git is rubble.
  16. Promotion via re-tagging the same image in the registry. Manifest didn’t change → ArgoCD sees no diff → no sync → confusion.
  17. ArgoCD running inside the workload cluster it manages, with no DR plan. Cluster outage = no recovery path.
  18. App-of-apps where the root has selfHeal: false. A drifted child can’t be auto-recovered. Root must self-heal.
  19. 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.
  20. 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/... vs envs/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 Application references a repo outside the project’s sourceRepos allow-list.
  • No new Application deploys to a destination outside the project’s destinations allow-list.
  • automated.prune: true and selfHeal: true unless 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-policy is 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 targetRevision is pinned to an exact version (no *, no latest).
  • 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, targetRevision is a tag or SHA — never a branch.
  • For prod, signed commits are required if AppProject has signatureKeys configured.

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 kyverno or policy-controller as a sync hook.

References