Argo CD Lesson 11 of 45

ApplicationSets In Depth: List, Cluster, Git, Matrix, Merge, SCM & Pull-Request Generators

There is a moment on every growing GitOps platform where the deploy repo stops feeling like infrastructure and starts feeling like data entry. You add the fourth cluster and copy-paste twenty Application manifests, changing one cluster name in each. You onboard a new microservice and hand-write the same fifteen-line Application you have written forty times before. The app-of-apps pattern — one parent Application whose source is a directory of child Application manifests — got you this far, but it has an obvious tax: every leaf is a file a human wrote, and the difference between most of those files is a single string.

The ApplicationSet is Argo CD’s answer. It is a controller-managed factory: you describe one Application template, hand it a generator that produces a set of parameters, and the controller stamps out one Application per parameter set — then keeps that set in lockstep with the generator forever. “Deploy this app to every cluster labelled env=prod” becomes a five-line selector that fans across AKS, EKS and GKE and grows itself the moment you register cluster twenty-one. “One app per directory in the mono-repo” becomes a glob that produces a new Application the instant someone commits a new folder. This is the single most leveraged object in Argo CD, and it is also the one that, misconfigured, can create — or delete — hundreds of Applications in one reconcile. This lesson teaches both halves: the power and the safety catch.

Everything here targets Argo CD 2.13+/3.x on Kubernetes 1.29+. Every manifest is a real, schema-correct argoproj.io/v1alpha1 ApplicationSet; every generator field and CLI flag is real. The outputs shown are the representative shape you will see — labelled as such — not a transcript of a specific run.


Why this matters

App-of-apps and ApplicationSet are often confused because they solve overlapping problems, but they work at different layers. App-of-apps is a convention: a normal Application that happens to point at a folder of other Application manifests. It has no templating and no intelligence — if you want ten child apps, ten YAML files exist in Git. ApplicationSet is a CRD with its own controller: it holds one template and generates the child Application objects itself, in the cluster, from live inputs. The child Applications it produces are ordinary Applications — the same objects app-of-apps would have contained — so ApplicationSet does not replace the sync engine; it replaces the authoring of the leaves.

Dimension App-of-apps ApplicationSet
What it is A regular Application pointing at a directory of child Application files A dedicated CRD (argoproj.io/v1alpha1) with its own controller
Where the children come from Hand-written YAML files committed to Git Generated in-cluster from a template + generator
Adding the Nth app/cluster Write another child manifest Nothing — the generator produces it automatically
Per-item variation Copy-paste and edit each file One template with {{ }} parameters
Deleting an item Delete its file, let the parent prune Remove the input; the controller deletes the Application
Best at Bootstrapping a fixed, small set of apps Fan-out over clusters, directories, PRs, org repos

The mental model to hold for the whole lesson: an ApplicationSet is for element in generator: create Application(template, element). The generator is the loop’s iterable; the template is the loop body; the output is a set of Application objects the controller owns and continuously reconciles against the generator. When you internalise that, every generator below is just “a different thing to loop over,” and every failure mode is either “the loop produced the wrong number of elements” or “the template rendered a bad Application.”

It is equally important to be precise about what ApplicationSet does not do:

ApplicationSet does It does not
Generate Application objects from a template + generator Sync anything itself — the application-controller still does that
Keep the set of Applications in step with the generator Build images, run CI, or render your Helm/Kustomize (the Application does that)
Own its generated Applications via owner references Bypass AppProject guardrails — generated apps obey their project
Support progressive, gated rollout of changes (RollingSync) Order resources inside an app (that is sync waves)
Preview its output offline (argocd admin applicationset generate) Protect you from a bad selector — a wrong glob yields zero or too many apps

If you have not yet met the base Application object, read Your First Application: the spec, source & destination first — an ApplicationSet’s template.spec is an Application spec, so every field you learned there applies unchanged inside the template.


The ApplicationSet CRD: a generator plus a template

Here is a complete, valid ApplicationSet with every structural piece labelled. Read it once top to bottom; the rest of this section dissects it.

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: example
  namespace: argocd                     # must live in the Argo CD namespace
spec:
  goTemplate: true                       # use Go text/template (recommended)
  goTemplateOptions: ["missingkey=error"]   # a missing key fails the render loudly
  generators:                            # ONE or more; each yields parameter sets
    - list:
        elements:
          - cluster: dev
            url: https://kubernetes.default.svc
  syncPolicy:                            # APPLICATIONSET-level policy (not the app's)
    preserveResourcesOnDeletion: false   # delete the AppSet => delete generated apps
    applicationsSync: create-update      # create + update generated apps, never delete
  template:                              # the Application blueprint, stamped per element
    metadata:
      name: '{{.cluster}}-guestbook'     # MUST be unique across all elements
      labels:
        env: '{{.cluster}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/argoproj/argocd-example-apps.git
        targetRevision: HEAD
        path: guestbook
      destination:
        server: '{{.url}}'
        namespace: '{{.cluster}}'
      syncPolicy:                        # APPLICATION-level policy (per generated app)
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true

The top-level spec has a small, fixed set of fields. Learn these and you can read any ApplicationSet:

spec field Required What it does
generators yes A list of one or more generators; their parameter sets are the loop’s iterable
template yes The Application blueprint; template.spec is a normal Application spec with {{ }} params
goTemplate no true switches templating from the legacy fasttemplate engine to Go text/template (recommended)
goTemplateOptions no Options passed to the Go template, e.g. ["missingkey=error"] to fail on a missing key
syncPolicy no ApplicationSet-level policy: preserveResourcesOnDeletion, applicationsSync
templatePatch no A Go-template string rendered per element and merged onto the Application (goTemplate only)
strategy no Progressive rollout of changes across generated apps (type: RollingSync)
ignoreApplicationDifferences no Fields on generated Applications the controller should stop reconciling (e.g. a manually tuned value)

The two syncPolicy blocks are not the same block

The single nastiest gotcha in the whole CRD is that syncPolicy appears twice and means two different things. spec.syncPolicy governs how the ApplicationSet controller treats the Applications it generates. spec.template.spec.syncPolicy is the ordinary Application sync policy (automated, syncOptions, retry) that ends up on each generated Application and governs how the application-controller syncs that app to its cluster. Put automated.selfHeal in the wrong one and it does nothing; put preserveResourcesOnDeletion in the template and Kubernetes rejects the Application.

spec.syncPolicy has just two fields, and both are about deletion and modification of the generated Applications themselves:

spec.syncPolicy field Values Meaning
preserveResourcesOnDeletion false (default) / true On true, deleting the ApplicationSet leaves its generated Applications (and their workloads) alive. On false, deleting the AppSet garbage-collects every generated Application.
applicationsSync create-only / create-update / create-delete Which mutations the controller may perform on generated Applications. Omit the field for the full default (create + update + delete).

The applicationsSync modes are worth their own table because each one produces a different surprise:

Mode Create new apps Update changed apps Delete removed apps Use it when
(omitted — default) yes yes yes Normal operation; the generator is the source of truth
create-only yes no no You want the AppSet to bootstrap apps, then let humans hand-edit them
create-update yes yes no You never want an accidental generator change to delete a live app
create-delete yes no yes You manage app internals manually but want removed elements cleaned up

create-update is the popular “seatbelt” for production fleets: a fat-fingered selector that suddenly matches zero clusters will not delete your apps — it just stops updating them. The trade-off is that legitimately removing a cluster leaves an orphaned Application you must delete by hand.

goTemplate vs the legacy engine — and why goTemplate now

Every parameter a generator emits is substituted into the template. There are two substitution engines, and mixing them up is the second-nastiest gotcha in this lesson.

Legacy (fasttemplate) goTemplate (goTemplate: true)
Reference a param {{cluster}} (no dot) {{.cluster}} (leading dot)
Nested value {{metadata.labels.env}} {{index .metadata.labels "env"}}
Conditionals / loops Not supported Full Go if/range/with
Functions None Sprig library (default, dig, trimPrefix, lower…)
Missing key Renders an empty string, silently With missingkey=error, fails the render loudly
Status Legacy, still the default if you omit the flag Recommended for all new ApplicationSets

Enable goTemplate: true and goTemplateOptions: ["missingkey=error"] on every new ApplicationSet. The reason is not style — it is safety. Under the legacy engine a typo like {{.clustr}} (or using {{.cluster}} when goTemplate is off) renders an empty string, and an Application with an empty destination.namespace or a blank name is accepted by the API server and then behaves bizarrely. With missingkey=error the same typo aborts the render, the ApplicationSet reports an error condition, and no broken Application is ever created. Fail loud, not silent.


The generators at a glance

Argo CD ships eight generators. They differ only in what they loop over; the template mechanics are identical for all of them.

Generator Loops over One Application per… Classic use Cloud edge?
List A static array of key/value maps you write Element in the list A fixed set of envs (dev/staging/prod) No
Cluster Registered cluster Secrets matching a selector Matching cluster Fan an add-on across a fleet Yes — AKS/EKS/GKE
Git (directories) Directories in a repo matching a glob Directory One app per team/service folder (mono-repo) No
Git (files) Config files in a repo matching a glob File Config-driven fleet, params from file content No
Matrix The cartesian product of two child generators Combination Every app on every matching cluster Inherits children
Merge Parameter sets joined on a key across generators Merged element Override per-cluster values Inherits children
SCM Provider Every repo in a GitHub/GitLab/etc. org Repository One app per service repo in an org Token/API edge
Pull Request Every open PR in a repo Open PR Ephemeral preview environment per PR Token/API edge

A ninth, Cluster Decision Resource, defers the cluster list to an external placement CRD; it is covered briefly at the end. The next sections take each generator in turn with a complete manifest and a when-to-use rule. At minimum, study List, Cluster, Git-directories, Matrix and Pull-Request closely — they are the five you will reach for constantly.


List generator — the static set

The List generator is the simplest: you write the elements yourself as an array of arbitrary key/value maps, and every key becomes a template parameter. Use it when the set is small, fixed, and known at author time — the canonical case being a handful of environments.

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: guestbook-envs
  namespace: argocd
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]
  generators:
    - list:
        elements:
          - env: dev
            url: https://kubernetes.default.svc
            replicas: "1"
          - env: staging
            url: https://kubernetes.default.svc
            replicas: "2"
          - env: prod
            url: https://kubernetes.default.svc
            replicas: "4"
  template:
    metadata:
      name: 'guestbook-{{.env}}'
      labels:
        env: '{{.env}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/argoproj/argocd-example-apps.git
        targetRevision: HEAD
        path: helm-guestbook
        helm:
          parameters:
            - name: replicaCount
              value: '{{.replicas}}'
      destination:
        server: '{{.url}}'
        namespace: 'guestbook-{{.env}}'
      syncPolicy:
        syncOptions:
          - CreateNamespace=true

This one ApplicationSet produces three Applications — guestbook-dev, guestbook-staging, guestbook-prod — each pointed at the same Helm chart but passing a different replicaCount and landing in its own namespace. The per-env values (replicas) ride along as list keys and feed a Helm parameter.

List generator field Type Notes
elements array of maps Each map is one parameter set; keys are free-form and become template params
elementsYaml string An alternative: a YAML/JSON string (often from another param) parsed into elements
template Application template An optional per-generator template override merged over the top-level one

Numeric-looking values like replicas: "1" are quoted deliberately. Template substitution is textual, and an unquoted 1 can be parsed as an integer that then fails to render into a string field. Quote list values that feed string parameters.


Cluster generator — the multi-cluster workhorse

The Cluster generator is where ApplicationSet earns its keep on a real platform. It loops over the clusters registered with Argo CD, optionally filtered by a label selector, and emits one Application per matching cluster. This is how you say “run the monitoring stack on every production cluster” once and have it stay true as the fleet grows — the exact multi-cloud fan-out this course is built around.

Argo CD stores every registered cluster as a Kubernetes Secret in the argocd namespace, labelled argocd.argoproj.io/secret-type: cluster (see multi-cluster registration & cluster Secrets). The Cluster generator’s selector matches labels on those Secrets, so the routing you want is a labelling decision you make once, at registration.

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: monitoring-fleet
  namespace: argocd
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]
  generators:
    - clusters:
        selector:
          matchLabels:
            env: prod
  template:
    metadata:
      name: 'monitoring-{{.name}}'
    spec:
      project: platform
      source:
        repoURL: https://github.com/acme/platform-gitops.git
        targetRevision: main
        path: addons/monitoring
      destination:
        server: '{{.server}}'
        namespace: monitoring
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true

Register five prod clusters with env=prod and this ApplicationSet produces five Applications — monitoring-aks-prod-1, monitoring-eks-prod-1, monitoring-gke-prod-1, and so on — each syncing the same add-on to its own cluster. Register a sixth: a sixth Application appears with no edit to any manifest.

Cluster generator field Type Notes
selector label selector matchLabels / matchExpressions against the cluster Secret’s labels; omit to match all
values map of strings Extra literal key/values injected as {{.values.<key>}}, templatable per cluster
template Application template Optional per-generator override

The parameters the Cluster generator exposes are fixed and worth memorising:

Parameter (goTemplate) Value
{{.name}} The cluster’s name as registered (the Secret’s name label)
{{.nameNormalized}} The name coerced to a valid RFC-1123 label (use this in metadata.name if names contain dots)
{{.server}} The cluster API server URL — put this in destination.server
{{index .metadata.labels "cloud"}} Any label you set on the cluster Secret
{{index .metadata.annotations "..."}} Any annotation on the cluster Secret
{{.values.<key>}} Any literal from the generator’s values block

Registering and labelling clusters on AKS, EKS and GKE

The generator is cloud-agnostic; only getting a kubeconfig context differs per cloud. The pattern is always: fetch the context, then argocd cluster add it with the labels your selector will match. The --label flag is repeatable.

# AKS — fetch the context, then register + label
az aks get-credentials --resource-group rg-prod --name aks-prod-1
argocd cluster add aks-prod-1 --label env=prod --label cloud=azure

# EKS — same shape; only the credential command changes
aws eks update-kubeconfig --name eks-prod-1 --region us-east-1
argocd cluster add arn:aws:eks:us-east-1:111122223333:cluster/eks-prod-1 \
  --label env=prod --label cloud=aws

# GKE — again, only get-credentials is cloud-specific
gcloud container clusters get-credentials gke-prod-1 --region us-central1
argocd cluster add gke_myproject_us-central1_gke-prod-1 \
  --label env=prod --label cloud=gcp
Cloud Managed service Fetch kubeconfig context Default context name Labels for the selector
Azure AKS az aks get-credentials -g RG -n NAME NAME env=prod, cloud=azure
AWS EKS aws eks update-kubeconfig --name NAME --region R arn:aws:eks:R:ACCT:cluster/NAME env=prod, cloud=aws
GCP GKE gcloud container clusters get-credentials NAME --region R gke_PROJECT_R_NAME env=prod, cloud=gcp

Two cloud-specific notes. First, the get-credentials step wires an exec-plugin into your kubeconfig for ongoing auth — kubelogin for AKS with Entra ID, the aws eks get-token helper for EKS, and gke-gcloud-auth-plugin for GKE — which must be installed where you run argocd cluster add. Second, argocd cluster add then creates an argocd-manager ServiceAccount and ClusterRoleBinding in the target cluster and stores that ServiceAccount’s bearer token in the cluster Secret, so day-to-day reconciliation does not depend on your personal cloud credentials. If you would rather keep cloud IAM in the loop end-to-end (Azure Workload Identity, EKS Pod Identity/IRSA, GKE Workload Identity), that is configured on the Secret’s config.execProviderConfig — a topic for the registration lesson, not the generator.

The in-cluster gotcha

The Cluster generator also implicitly includes the local cluster Argo CD runs on — https://kubernetes.default.svc, known as in-cluster. That surprises people two ways. If you write a selector, the in-cluster is included only if it matches, and the in-cluster has no cluster Secret and no labels by default, so any non-empty selector silently excludes in-cluster. Conversely, an empty selector (selector: {} or no selector at all) matches every registered cluster plus in-cluster, which may deploy your add-on somewhere you did not intend. Decide deliberately: label the in-cluster if you want it in a selector, and never use an empty selector on a generator whose apps should not touch the Argo CD cluster itself.


Git generator — one app per directory or file (the mono-repo pattern)

The Git generator loops over the contents of a repository. It has two modes, and they solve two different problems.

Directory mode: one Application per folder

Point it at a glob of directories and it produces one Application per matching directory. This is the mono-repo pattern: a repo where each subfolder is a deployable unit, and adding a folder adds an app for free.

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: tenant-apps
  namespace: argocd
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]
  generators:
    - git:
        repoURL: https://github.com/acme/app-config.git
        revision: main
        directories:
          - path: apps/*
          - path: apps/experimental/*
            exclude: true          # subtract a subtree from the matches above
  template:
    metadata:
      name: '{{.path.basename}}'   # the folder name, e.g. "checkout"
    spec:
      project: tenants
      source:
        repoURL: https://github.com/acme/app-config.git
        targetRevision: main
        path: '{{.path.path}}'     # the full path, e.g. "apps/checkout"
      destination:
        server: https://kubernetes.default.svc
        namespace: '{{.path.basename}}'
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true
Git directory field Type Notes
repoURL string The repository to scan
revision string Branch, tag, or commit to read
directories[].path glob Directories to include; supports * and **
directories[].exclude bool When true, subtracts the matched paths from the included set
pathParamPrefix string Prefixes the path params (needed when nesting two git generators in a matrix)
requeueAfterSeconds int How often to re-scan the repo when no webhook is configured (default 180)

The directory generator exposes a family of path parameters — these are the ones the brief and the exams care about:

Parameter (goTemplate) For apps/checkout yields Use for
{{.path.path}} apps/checkout The Application’s source.path
{{.path.basename}} checkout The Application name and target namespace
{{.path.basenameNormalized}} checkout Same, but RFC-1123-safe (dots/underscores → dashes)
{{index .path.segments 0}} apps Selecting a path segment by index
{{.path.segments}} ["apps","checkout"] Ranging over segments in a Go template

The legacy-engine equivalents are {{path}}, {{path.basename}}, and {{path[0]}}. If you copy a goTemplate example into a legacy ApplicationSet (or forget goTemplate: true), {{.path.basename}} renders empty and every generated app gets a blank name — the exact silent failure missingkey=error exists to prevent.

File mode: parameters from config files

Point the same generator at a glob of files instead, and each matched file is parsed (JSON or YAML) and its contents become the template parameters. Use this when each unit needs richer, structured config than a folder name — a per-cluster registration file, say.

generators:
  - git:
      repoURL: https://github.com/acme/platform-gitops.git
      revision: main
      files:
        - path: "clusters/**/config.json"

Given a clusters/prod/eks-1/config.json like:

{
  "cluster": { "name": "eks-prod-1", "address": "https://eks-prod-1.example.com" },
  "team": "payments"
}

the template can reference the file’s contents directly — {{.cluster.name}}, {{.cluster.address}}, {{.team}} — plus {{.path.basename}} for the folder the file lives in. File mode is the bridge between “GitOps for apps” and “GitOps for cluster configuration”: a reviewer approves a new config.json, and the app appears.

Git file field Type Notes
files[].path glob Files to read; each is parsed into a parameter set
Parameters from file The parsed JSON/YAML content, merged with {{.path.*}} metadata

Matrix generator — the cartesian product

The Matrix generator combines two child generators and emits one Application for every combination of their outputs. The archetypal use is the platform-team dream: “every add-on on every prod cluster,” expressed as git (directories) × clusters.

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: addons-everywhere
  namespace: argocd
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]
  generators:
    - matrix:
        generators:
          - git:
              repoURL: https://github.com/acme/platform-gitops.git
              revision: main
              directories:
                - path: addons/*
          - clusters:
              selector:
                matchLabels:
                  env: prod
  template:
    metadata:
      name: '{{.path.basename}}-{{.name}}'   # addon name + cluster name = unique
    spec:
      project: platform
      source:
        repoURL: https://github.com/acme/platform-gitops.git
        targetRevision: main
        path: '{{.path.path}}'
      destination:
        server: '{{.server}}'
        namespace: '{{.path.basename}}'
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true

With 4 add-on directories and 5 prod clusters, this produces 20 Applications, each named <addon>-<cluster>. Add an add-on folder: 5 new apps. Register a prod cluster: 4 new apps. That multiplicative behaviour is the whole point — and the whole danger.

Matrix generator fact Detail
generators Exactly two child generators (nest a matrix inside a matrix for three-way products)
Parameters The union of both children’s parameters is available in the template
Output count len(generatorA) × len(generatorB) — multiplicative, not additive
Name collisions If two combinations render the same metadata.name, the apps fight; always include a param from each generator in the name
Nested pathParamPrefix Required if both children are git generators, so their path params do not clash

The multiplication is why the diagram badges this generator in red. Four apps and five clusters is twenty Applications; forty apps and twenty clusters is eight hundred, generated from a single commit and, by default, applied all at once. Two guardrails matter here: always preview with argocd admin applicationset generate before applying (shown in the safety section), and for large fleets add a strategy: RollingSync so a matrix change rolls out cluster-by-cluster instead of as a thundering herd.

Left-to-right ApplicationSet fan-out: a Git repo and generator inputs feed the ApplicationSet controller and its generator, which templates N Applications that fan out across AKS, EKS and GKE prod clusters and reconcile to Synced and Healthy, with a warning that deleting the ApplicationSet prunes all generated apps

Read the diagram left to right: the input (a Git repo’s directories, or the labels on registered cluster Secrets) feeds the ApplicationSet and its generator; the generator produces parameters; the template stamps out one Application per parameter set; and each Application fans out to its target — here the three managed Kubernetes services across clouds — reconciling until all N report Synced/Healthy. Badge 1 marks the generator as the fan-out driver (a wrong selector yields zero apps); badge 3 flags the matrix explosion; badge 4 the goTemplate-or-empty rule; badge 5 the cluster-label selector across AKS/EKS/GKE; and badge 6 the deletion danger the safety section is about.


Merge generator — override values by key

Where Matrix multiplies generators, Merge joins them. It takes a base generator plus one or more override generators and merges their parameter sets on a merge key: for each element from the base, if a later generator has an element with the same key value, its fields are layered on top. This is the clean way to say “all prod clusters get the default config, except these two, which get an override.”

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: monitoring-with-overrides
  namespace: argocd
spec:
  goTemplate: true
  generators:
    - merge:
        mergeKeys:
          - server                     # join base + overrides on the cluster URL
        generators:
          - clusters:                  # BASE: every prod cluster
              selector:
                matchLabels:
                  env: prod
          - list:                      # OVERRIDE: one cluster gets longer retention
              elements:
                - server: https://eks-prod-1.example.com
                  retention: "90d"
  template:
    metadata:
      name: 'monitoring-{{.name}}'
    spec:
      project: platform
      source:
        repoURL: https://github.com/acme/platform-gitops.git
        targetRevision: main
        path: addons/monitoring
        helm:
          parameters:
            - name: prometheus.retention
              value: '{{dig "retention" "15d" .}}'   # default 15d unless overridden
      destination:
        server: '{{.server}}'
        namespace: monitoring

Every prod cluster gets the monitoring stack; the one cluster whose server matches the override list gets retention: 90d, and every other cluster falls back to 15d via Sprig’s dig (which safely reads a key with a default). The base generator determines which apps exist; the override generators only modify matching ones — an element that appears solely in an override generator is ignored.

Merge generator fact Detail
mergeKeys The parameter key(s) used to join elements across generators
generators The first is the base (defines the set); later ones override matching elements
Non-matching overrides An override element with no base match is dropped, not added
Common pairing clusters (base) + list (targeted per-cluster overrides)

SCM Provider generator — one app per repo in an org

The SCM Provider generator scans a whole Git organisation and emits one Application per repository that passes your filters. Use it when your unit of deployment is “a repo” and repos come and go — a platform where every service lives in its own repo and should get an Application automatically once it contains a deploy manifest.

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: org-services
  namespace: argocd
spec:
  goTemplate: true
  generators:
    - scmProvider:
        github:
          organization: acme
          tokenRef:
            secretName: github-token
            key: token
        filters:
          - repositoryMatch: ^service-.*      # only repos named service-*
            pathsExist:
              - deploy/kustomization.yaml      # …that actually have a deploy dir
  template:
    metadata:
      name: '{{.repository}}'
    spec:
      project: default
      source:
        repoURL: '{{.url}}'
        targetRevision: '{{.branch}}'
        path: deploy
      destination:
        server: https://kubernetes.default.svc
        namespace: '{{.repository}}'
      syncPolicy:
        syncOptions:
          - CreateNamespace=true
SCM provider Auth field Notes
github tokenRef (PAT) or GitHub App appSecretName for App auth; api for GitHub Enterprise
gitlab tokenRef api for self-hosted GitLab; scans groups
gitea tokenRef api required (self-hosted)
bitbucket / bitbucketServer basicAuth / bearerToken Cloud vs. self-hosted variants
azureDevOps accessTokenRef organization + teamProject
awsCodeCommit IAM role / region Scans CodeCommit repos by tag

The filters are what keep an org generator from creating an Application for every README-only repo:

SCM filter Effect
repositoryMatch Regex the repository name must match
pathsExist Only include repos that contain these paths (e.g. a deploy dir)
pathsDoNotExist Exclude repos containing these paths
labelMatch Match a repository topic/label
branchMatch Restrict which branches generate apps

Because it holds an org-scoped token, the SCM generator is a security-sensitive object: scope the token to read-only repo metadata, store it in a Secret referenced by tokenRef, and never inline it. Rotate it like any other credential.


Pull Request generator — an ephemeral preview per PR

The Pull Request generator is the one that makes reviewers smile: it emits one Application per open pull request, so every PR gets its own live preview environment — and, crucially, when the PR merges or closes, the generator stops producing that element and the Application (and its namespace) is torn down automatically.

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: guestbook-previews
  namespace: argocd
spec:
  goTemplate: true
  syncPolicy:
    preserveResourcesOnDeletion: false   # PR close => Application removed => resources pruned
  generators:
    - pullRequest:
        github:
          owner: acme
          repo: guestbook
          tokenRef:
            secretName: github-token
            key: token
          labels:
            - preview                    # only PRs carrying the "preview" label
        requeueAfterSeconds: 120         # poll every 2 min (prefer a webhook in prod)
  template:
    metadata:
      name: 'guestbook-pr-{{.number}}'
    spec:
      project: previews
      source:
        repoURL: https://github.com/acme/guestbook.git
        targetRevision: '{{.head_sha}}'  # the PR's commit, not a branch
        path: manifests
      destination:
        server: https://kubernetes.default.svc
        namespace: 'preview-pr-{{.number}}'
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true

The auto-teardown is the whole value, and it hinges on spec.syncPolicy.preserveResourcesOnDeletion: false (the default). When a PR closes, the generator no longer lists it, the controller deletes the guestbook-pr-42 Application, and because that Application carries the resources finalizer, its preview-pr-42 namespace and everything in it are pruned. Set preserveResourcesOnDeletion: true here and you would leak a namespace per merged PR — the opposite of what you want.

PR generator field Notes
github / gitlab / gitea / bitbucketServer / azuredevops The SCM to poll for open PRs
tokenRef Secret holding the API token
filters / labels Restrict which PRs generate apps (e.g. only preview-labelled)
requeueAfterSeconds Poll interval when no webhook is wired

The parameters a PR exposes drive the preview’s identity and pinning:

Parameter (goTemplate) Value
{{.number}} PR number — the backbone of unique names and namespaces
{{.branch}} / {{.branch_slug}} Source branch (slug is DNS-safe)
{{.target_branch}} The branch the PR merges into
{{.head_sha}} The PR head commit — pin targetRevision to this for a reproducible preview
{{.head_short_sha}} Short commit, handy in image tags
{{.labels}} PR labels (GitHub/GitLab)

For production, replace polling with a webhook so previews appear within seconds of a PR opening rather than on the next requeueAfterSeconds tick, and put previews in their own permissive AppProject (previews) so a broken PR cannot escape into prod namespaces.

Cluster Decision Resource generator (brief)

The last generator, clusterDecisionResource, hands the cluster-selection decision to an external placement controller — typically Open Cluster Management’s PlacementDecision or a similar duck-typed CRD with a status.decisions list of cluster names. You point the generator at that resource and a small ConfigMap that tells it how to read the resource’s status, and it emits one Application per cluster the external controller decided on:

generators:
  - clusterDecisionResource:
      configMapRef: ocm-placement    # tells the generator how to read the CR
      name: prod-placement           # the PlacementDecision to follow
      requeueAfterSeconds: 180

Reach for it only when a dedicated placement engine already owns “which clusters” — otherwise the Cluster generator’s label selector is simpler and needs no extra controller.


Templating in depth: goTemplate, functions, and templatePatch

Every generator hands the template a flat (or nested) set of parameters; the difference between generators is which parameters. This reference table collects the ones you will actually type, so you can stop guessing at param names:

Generator Key parameters (goTemplate)
List Whatever keys you put in elements{{.env}}, {{.url}}, …
Cluster {{.name}}, {{.nameNormalized}}, {{.server}}, {{index .metadata.labels "k"}}, {{.values.k}}
Git directories {{.path.path}}, {{.path.basename}}, {{.path.basenameNormalized}}, {{index .path.segments N}}
Git files The parsed file content ({{.cluster.name}}…) plus {{.path.basename}}, {{.path.filename}}
Matrix The union of both child generators’ parameters
Merge The base element’s params, overridden per mergeKeys
SCM Provider {{.organization}}, {{.repository}}, {{.url}}, {{.branch}}, {{.sha}}
Pull Request {{.number}}, {{.branch}}, {{.target_branch}}, {{.head_sha}}, {{.labels}}

Because goTemplate is real Go text/template with the Sprig function library, you can transform parameters inline instead of forcing every value to be pre-computed in the generator:

Function Example Use
default {{default "15d" .retention}} Fall back when a param is empty
dig {{dig "retention" "15d" .}} Read a possibly-absent key with a default (great with Merge)
trimPrefix / trimSuffix {{trimPrefix "apps/" .path.path}} Strip a fixed prefix from a path
lower / upper {{lower .branch}} Normalise case for names
replace {{replace "_" "-" .name}} Coerce to DNS-safe strings
index {{index .metadata.labels "team"}} Read a nested map value
if / range / with {{if eq .env "prod"}}…{{end}} Conditionals and loops in the template

templatePatch — when the template alone cannot express it

Sometimes a field must exist for some generated apps and be absent for others — a conditional syncPolicy.automated, an extra annotation on prod only. You cannot express “omit this whole block” with plain substitution, so ApplicationSet provides templatePatch: a Go-template string, rendered per element, whose output is merged onto the generated Application as a strategic patch. It requires goTemplate: true.

spec:
  goTemplate: true
  generators:
    - list:
        elements:
          - env: dev
          - env: prod
  template:
    metadata:
      name: 'app-{{.env}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/acme/app.git
        targetRevision: HEAD
        path: manifests
      destination:
        server: https://kubernetes.default.svc
        namespace: '{{.env}}'
  templatePatch: |
    {{- if ne .env "prod" }}
    spec:
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
    {{- end }}

Non-prod apps get automated self-heal; the prod app renders an empty patch and stays manual-sync. templatePatch is powerful and easy to abuse — its output must be valid YAML/JSON or the render fails — so reach for it only when a conditional structure (not just a conditional value) is genuinely needed.


The safety story: preview, delete semantics, and the big danger

Everything that makes ApplicationSet powerful — one object producing many Applications — is exactly what makes it dangerous. A generator does not know you made a mistake. Change a selector to match nothing and, under the default policy, the controller dutifully deletes every Application that selector used to produce, cascading to live workloads. Point a matrix at the wrong glob and it creates hundreds of Applications that all hammer your repo-server at once. The controller is a faithful loop; the blast radius is the size of the generator’s output.

⚠️ The number-one production incident with ApplicationSets is a generator change that silently adds or removes hundreds of elements. Deleting the ApplicationSet, narrowing a selector, renaming a label, or fixing a “harmless” glob can each create or destroy Applications in bulk on the next reconcile. Treat any change to generators as a change that can delete production.

Three controls form the seatbelt, and you should understand all three before you run kubectl apply on anything non-trivial.

1. Preview offline, every time. argocd admin applicationset generate renders the exact Applications an ApplicationSet would produce, from a file, without touching the cluster. This is your dry-run:

# Render the Applications this AppSet would create — nothing is applied
argocd admin applicationset generate addons-everywhere.yaml

# Representative shape: one Application document per generated element
# apiVersion: argoproj.io/v1alpha1
# kind: Application
# metadata:
#   name: monitoring-eks-prod-1
# ...
# ---
# apiVersion: argoproj.io/v1alpha1
# kind: Application
# metadata:
#   name: monitoring-gke-prod-1
# ...

Count the documents. If a four-add-on, five-cluster matrix does not print exactly twenty Applications, your selector or glob is wrong — find out before the controller does.

2. Understand delete semantics. By default the generated Applications are owned by the ApplicationSet (owner references), so deleting the ApplicationSet garbage-collects all of them, and the resources finalizer then prunes their workloads. spec.syncPolicy.preserveResourcesOnDeletion: true breaks that chain and keeps the apps alive when the AppSet is deleted. spec.syncPolicy.applicationsSync: create-update (or create-only) stops the controller from deleting apps at all, so a bad selector cannot prune your fleet — it can only stop updating it.

3. Gate the rollout. For large fan-outs, spec.strategy.type: RollingSync rolls changes across generated apps in labelled steps instead of all at once, so a bad change is caught after the first cluster, not after the eight-hundredth. This is the fleet-scale safety valve.

When you are unsure which generator even fits the problem, this decision table shortcuts the choice:

If you need to… Use Because
Deploy to a small, fixed set you type by hand List The set is known and rarely changes
Deploy an add-on to every cluster matching a label Cluster The fleet is the loop, selected by label
Turn each folder in a repo into an app Git (directories) The mono-repo layout is the app list
Drive apps from structured per-item config files Git (files) Config lives in Git, params come from file content
Put every app on every matching cluster Matrix You need the cartesian product
Apply per-cluster value overrides to a base set Merge Override without redefining the whole set
Make an app per repo in an org SCM Provider The unit of deploy is a repository
Give every PR a preview environment Pull Request Ephemeral, auto-torn-down per PR
Follow an external placement controller’s decisions Cluster Decision Resource Another system owns “which clusters”

And the danger table — the ways a generator turns into an outage:

Danger What happens Guardrail
Selector matches nothing Every previously-generated app is deleted applicationsSync: create-update; preview first
Matrix explosion Hundreds of apps created and applied at once Preview count; strategy: RollingSync; repo-server parallelism cap
Delete the AppSet All generated apps + their workloads pruned preserveResourcesOnDeletion: true when appropriate
Non-unique template name Combinations collide; apps overwrite each other Include a param from each generator in metadata.name
Empty selector on Cluster gen Includes in-cluster unexpectedly Never leave the selector empty for fleet add-ons

Hands-on lab

You will build three ApplicationSets on a free local cluster — a List, a Git-directory, and a Cluster generator — apply them, watch the Applications they generate, and tear them down understanding exactly what deletion prunes. Nothing here bills: it is all a local kind cluster and public repos.

Prerequisites. A local cluster with Argo CD installed and the argocd CLI logged in — the state you reach at the end of the install lesson:

# A throwaway local cluster
kind create cluster --name argocd-lab

# Argo CD (namespace + core install)
kubectl create namespace argocd
kubectl apply -n argocd \
  -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

# Wait for the controllers, then confirm the ApplicationSet controller is up
kubectl -n argocd rollout status deploy/argocd-applicationset-controller

What just happened: the standard install includes the applicationset-controller alongside the application-controller, repo-server, and API server. If that Deployment is Available, ApplicationSets will reconcile.

Step 1 — A List generator across three environments. Save this as lab-list.yaml:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: lab-guestbook-envs
  namespace: argocd
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]
  generators:
    - list:
        elements:
          - env: dev
            replicas: "1"
          - env: staging
            replicas: "2"
          - env: prod
            replicas: "3"
  template:
    metadata:
      name: 'guestbook-{{.env}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/argoproj/argocd-example-apps.git
        targetRevision: HEAD
        path: helm-guestbook
        helm:
          parameters:
            - name: replicaCount
              value: '{{.replicas}}'
      destination:
        server: https://kubernetes.default.svc
        namespace: 'guestbook-{{.env}}'
      syncPolicy:
        automated: {}
        syncOptions:
          - CreateNamespace=true

Preview it before applying, then apply:

# Dry-run: see exactly three Applications, nothing applied
argocd admin applicationset generate lab-list.yaml | grep '^  name:'
#   name: guestbook-dev
#   name: guestbook-staging
#   name: guestbook-prod   (representative — three documents)

kubectl apply -f lab-list.yaml

What just happened: one ApplicationSet became three Applications, each a different replicaCount in its own namespace, from a single 25-line file.

Step 2 — A Git-directory generator, one app per folder. Save as lab-git.yaml. The *guestbook glob matches exactly the three deployable guestbook folders in the public example repo:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: lab-git-dirs
  namespace: argocd
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]
  generators:
    - git:
        repoURL: https://github.com/argoproj/argocd-example-apps.git
        revision: HEAD
        directories:
          - path: '*guestbook'      # guestbook, helm-guestbook, kustomize-guestbook
  template:
    metadata:
      name: 'dir-{{.path.basename}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/argoproj/argocd-example-apps.git
        targetRevision: HEAD
        path: '{{.path.path}}'
      destination:
        server: https://kubernetes.default.svc
        namespace: 'lab-{{.path.basename}}'
      syncPolicy:
        automated: {}
        syncOptions:
          - CreateNamespace=true
kubectl apply -f lab-git.yaml

What just happened: the generator scanned the repo, found three directories ending in guestbook, and produced dir-guestbook, dir-helm-guestbook, and dir-kustomize-guestbook — Argo CD auto-detected plain-YAML, Helm and Kustomize in each. Commit a fourth matching folder and a fourth app would appear on the next scan.

Step 3 — A Cluster generator fanning to env=prod. On a single kind cluster you have only the in-cluster, which is unlabelled, so first label it, then apply a selector-based ApplicationSet. Save as lab-cluster.yaml:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: lab-cluster-fanout
  namespace: argocd
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]
  generators:
    - clusters:
        selector:
          matchLabels:
            env: prod
  template:
    metadata:
      name: 'fleet-{{.nameNormalized}}'
    spec:
      project: default
      source:
        repoURL: https://github.com/argoproj/argocd-example-apps.git
        targetRevision: HEAD
        path: guestbook
      destination:
        server: '{{.server}}'
        namespace: fleet-guestbook
      syncPolicy:
        automated: {}
        syncOptions:
          - CreateNamespace=true
# The in-cluster is represented by a Secret named "in-cluster" only after you
# register or label it. Label it so a selector can match it:
kubectl -n argocd label secret \
  $(kubectl -n argocd get secret -l argocd.argoproj.io/secret-type=cluster \
     -o name | head -n1) env=prod --overwrite 2>/dev/null \
  || echo "No cluster Secret yet — see note below"

kubectl apply -f lab-cluster.yaml

What just happened: the Cluster generator matched the one Secret labelled env=prod and produced a single Application, fleet-in-cluster. On a real platform with AKS, EKS and GKE clusters each labelled env=prod, the same ApplicationSet would produce one Application per cloud — the multi-cluster fan-out, with no manifest edits. If your lab has no cluster Secret at all yet, that is expected on a fresh single-node install; the multi-cluster registration lesson shows how argocd cluster add creates and labels them.

Step 4 — See what you built.

kubectl get applicationset -n argocd
# NAME                   AGE
# lab-guestbook-envs     2m
# lab-git-dirs           1m
# lab-cluster-fanout     30s

kubectl get applications -n argocd
# NAME                     SYNC STATUS   HEALTH STATUS
# guestbook-dev            Synced        Healthy
# guestbook-staging        Synced        Healthy
# guestbook-prod           Synced        Healthy
# dir-guestbook            Synced        Healthy
# dir-helm-guestbook       Synced        Healthy
# dir-kustomize-guestbook  Synced        Healthy
# fleet-in-cluster         Synced        Healthy      (representative shape)

# Which ApplicationSet owns a given Application?
kubectl get application guestbook-dev -n argocd \
  -o jsonpath='{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}{"\n"}'
# ApplicationSet/lab-guestbook-envs

What just happened: three ApplicationSets produced seven Applications between them, and each Application carries an ownerReference back to the ApplicationSet that generated it. That owner reference is the mechanism behind the deletion semantics you are about to see.

Step 5 — Teardown, with the delete semantics made explicit.

# Deleting an ApplicationSet deletes the Applications it owns,
# and (because they carry the resources finalizer) their workloads too.
kubectl delete applicationset lab-guestbook-envs -n argocd
kubectl delete applicationset lab-git-dirs -n argocd
kubectl delete applicationset lab-cluster-fanout -n argocd

# Confirm the generated Applications are gone
kubectl get applications -n argocd
# No resources found in argocd namespace.

# Remove the whole cluster
kind delete cluster --name argocd-lab

⚠️ That kubectl delete applicationset is the exact command that, in production, prunes every generated app and its running workloads. If you ever need to delete an ApplicationSet but keep its apps running, set spec.syncPolicy.preserveResourcesOnDeletion: true first, apply, and only then delete — or the generated Applications go with it.


Common mistakes and troubleshooting

ApplicationSet failures cluster into “the generator produced the wrong set” and “the template rendered a bad Application.” This table covers the ones you will actually hit; the prose after it dwells on the three that cost the most hours.

Symptom Likely cause Fix
ApplicationSet exists but zero Applications appear Selector matches no clusters, or the Git path glob matches no directories Preview with argocd admin applicationset generate; check kubectl describe applicationset conditions; verify labels/glob
Generated app has an empty name or blank namespace Legacy {{name}} used under goTemplate: true (or {{.name}} without the flag) Match the syntax to the engine; add goTemplateOptions: ["missingkey=error"] to fail loud
Suddenly hundreds of Applications created Matrix generator over a too-broad glob or selector Preview the count first; add strategy: RollingSync; cap repo-server parallelism
Deleting the ApplicationSet pruned everything Default preserveResourcesOnDeletion: false + owner references Set preserveResourcesOnDeletion: true before deleting if apps must survive
PR previews not torn down on merge preserveResourcesOnDeletion: true, or webhook/poll not firing Set it to false; verify requeueAfterSeconds or the webhook; check the token
error listing repositories / 401 from SCM or PR generator Token missing, expired, or under-scoped Check the tokenRef Secret; grant read scope; set api for self-hosted/enterprise
Cluster generator skips a cluster you expected The cluster Secret lacks the selector’s label Label it: argocd cluster add CTX --label env=prod, or edit the Secret’s labels
Two generated apps fight / overwrite each other Template metadata.name is not unique across elements Include a param from every generator in the name ({{.path.basename}}-{{.name}})
Template change does not update existing apps applicationsSync: create-only freezes updates Switch to default or create-update; create-only only ever creates
Removed a directory but the app stayed applicationsSync: create-update never deletes Use default or create-delete; or delete the orphaned Application by hand
Generated Application shows ComparisonError The rendered source.path/repoURL is wrong for that element Inspect the rendered app: kubectl get application NAME -n argocd -o yaml

Zero apps is almost always the selector or the glob. When an ApplicationSet produces nothing, resist editing the template — the template never ran, because the generator emitted no elements. Run argocd admin applicationset generate on the file: if it prints no documents, the generator is the problem. For a Cluster generator, list your labelled Secrets (kubectl -n argocd get secret -l argocd.argoproj.io/secret-type=cluster --show-labels) and confirm one actually carries the selector’s label. For a Git generator, remember the glob is relative to the repo root and that apps/* matches directories inside apps, not apps itself. kubectl describe applicationset <name> surfaces the controller’s ErrorOccurred and ParametersGenerated conditions, which name the failure directly.

The goTemplate/legacy mismatch is invisible without missingkey=error. Under the legacy engine, {{.cluster}} is not a valid reference, so it renders as the literal empty string — and an Application with name: "" or namespace: "" is often accepted by the API server and then misbehaves in confusing ways (deploys to default, or collides with another empty-named app). The fix is preventive: set goTemplate: true and goTemplateOptions: ["missingkey=error"] on every ApplicationSet, so a wrong reference aborts the render and shows up as an error condition instead of a silently broken app. If you inherit an ApplicationSet with mysterious empty fields, check goTemplate first.

Delete blast radius is a property of ownership, not intent. The controller does not distinguish “I meant to delete this” from “my selector accidentally matches nothing now” — in both cases the generated set shrank, so the corresponding Applications are removed. Before any change to a generators block on a production ApplicationSet, preview the new output and diff the app count against the current kubectl get applications. If the count drops unexpectedly, stop: you are about to prune live workloads. applicationsSync: create-update is the seatbelt that turns “delete my fleet” into merely “stop updating my fleet,” and it is cheap insurance on anything that matters.


Cheat-sheet

Generator selection at a glance:

Generator Reach for it when
list A small fixed set you type by hand (envs, regions)
clusters Fan an app across registered clusters by label (the fleet workhorse)
git (directories) One app per folder in a mono-repo
git (files) Params come from structured config files in Git
matrix Cartesian product — every app on every matching cluster
merge Layer per-item overrides onto a base set, joined by key
scmProvider One app per repo across a whole org
pullRequest An ephemeral preview per open PR, auto-torn-down
clusterDecisionResource An external placement controller owns cluster selection

Templating quick-reference (assume goTemplate: true):

You want Write
A generator param {{.env}} (leading dot)
A nested label {{index .metadata.labels "team"}}
The folder name / full path {{.path.basename}} / {{.path.path}}
A path segment by index {{index .path.segments 0}}
A default for a missing key {{default "x" .maybe}} or {{dig "k" "x" .}}
Conditional structure templatePatch: with {{if …}}…{{end}}
Fail on a typo’d param goTemplateOptions: ["missingkey=error"]

Commands and CRD fields:

Command / field What it does
argocd admin applicationset generate FILE Render the Applications an AppSet would create — offline dry-run
kubectl get applicationset -n argocd List ApplicationSets
kubectl describe applicationset NAME -n argocd Show generation conditions (ParametersGenerated, ErrorOccurred)
kubectl get applications -n argocd See the generated Applications and their sync/health
spec.syncPolicy.preserveResourcesOnDeletion Keep generated apps when the AppSet is deleted
spec.syncPolicy.applicationsSync create-only / create-update / create-delete guardrail
spec.strategy.type: RollingSync Gate a fan-out rollout into labelled steps
spec.templatePatch Post-render, per-element patch onto the Application (goTemplate)

Interview and exam questions

Q: What problem does ApplicationSet solve that app-of-apps does not? A: App-of-apps still requires a hand-written child Application manifest per app — pure boilerplate when the only difference is a cluster name or a values file. ApplicationSet templates the children from a generator, so adding the Nth app or cluster requires no new manifest: the controller generates and reconciles the leaves for you.

Q: An ApplicationSet has two syncPolicy blocks. What is the difference? A: spec.syncPolicy is ApplicationSet-level and controls how the controller treats the generated ApplicationspreserveResourcesOnDeletion and applicationsSync. spec.template.spec.syncPolicy is the ordinary Application sync policy (automated, syncOptions, retry) that ends up on each generated app and controls how it syncs to its cluster. They are not interchangeable.

Q: Why enable goTemplate: true and missingkey=error on every ApplicationSet? A: The legacy fasttemplate engine cannot do conditionals or safe nested lookups, and a mistyped or missing parameter renders as an empty string — producing a broken-but-accepted Application. goTemplate gives you Go templating plus the Sprig functions, and missingkey=error turns a bad reference into a render failure and an error condition instead of a silent empty field.

Q: How does the Cluster generator decide which clusters to target, and what is the in-cluster gotcha? A: It matches a label selector against the labels on each registered cluster’s Secret (argocd.argoproj.io/secret-type: cluster). The gotcha is the local in-cluster: it has no Secret or labels by default, so any non-empty selector excludes it, while an empty selector includes it plus every registered cluster — potentially deploying somewhere you did not intend.

Q: You register three prod clusters on AKS, EKS and GKE and label them env=prod. Sketch the ApplicationSet that runs one add-on on all three. A: A Cluster generator with selector.matchLabels.env: prod, and a template whose metadata.name is addon-{{.name}} and destination.server is {{.server}}. It produces one Application per matching cluster and grows automatically when a fourth env=prod cluster is registered — no manifest edit.

Q: What is the difference between the Matrix and Merge generators? A: Matrix produces the cartesian product of two child generators — one app per combination (apps × clusters). Merge joins generators on a merge key and layers later generators’ fields onto matching base elements — same set of apps, with per-item overrides. Matrix multiplies the count; Merge does not.

Q: How does the Pull Request generator give you preview environments, and how do they get cleaned up? A: It emits one Application per open PR, typically pinning source.targetRevision to {{.head_sha}} and namespacing by {{.number}}. When the PR closes, the generator stops listing it, the controller deletes the Application, and — because preserveResourcesOnDeletion is false (the default) and the app carries the resources finalizer — the preview namespace and its workloads are pruned automatically.

Q: A colleague narrowed a Cluster generator’s selector and half the fleet’s apps disappeared. What happened and how do you prevent it? A: The generated set shrank, so under the default policy the controller deleted the Applications that no longer matched — cascading to their workloads. Prevent it by previewing with argocd admin applicationset generate and diffing the app count before applying, and by setting applicationsSync: create-update so the controller never deletes generated apps, only stops updating them.

Q: How do you safely preview what an ApplicationSet will create before applying it? A: argocd admin applicationset generate <file> renders the exact Applications the ApplicationSet would produce, from the file, without touching the cluster. Count the documents and check the names; if the number is not what you expect, the selector or glob is wrong.

Q: What does applicationsSync: create-update actually change? A: It permits the controller to create and update generated Applications but never delete them. Removing an element (or a selector matching nothing) will not prune the corresponding app — protecting production from accidental fan-in — at the cost of leaving orphaned Applications you must delete by hand when a removal is intentional.

Q: When would you use the Git files generator over the directories generator? A: When each unit needs structured configuration richer than a folder name. Directory mode gives you path params only; file mode parses each matched JSON/YAML file and exposes its contents as parameters ({{.cluster.name}}, {{.team}}), which is ideal for config-driven fleets where a reviewer adds a config.json to onboard a cluster or tenant.

Q: What is templatePatch for, and what is its constraint? A: It applies a Go-template-rendered patch onto each generated Application after the main template renders — used to add or omit whole blocks conditionally (e.g. automated sync on non-prod only), which plain substitution cannot express. It requires goTemplate: true, and its rendered output must be valid YAML/JSON or the render fails.


Key takeaways

argocdgitopskubernetesapplicationsetgeneratorsakseksgkemulti-clustergotemplatematrix-generatorpull-requestmonorepohelm
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments