{"componentChunkName":"component---src-templates-blog-post-js","path":"/blog/2026-08-07-argocd-applicationsets-many-clusters/","result":{"data":{"site":{"siteMetadata":{"title":"M.Hassan Ahmed","author":"Hassan11196"}},"markdownRemark":{"id":"bc50fb41-9c0c-5daf-9241-6785f1518aa4","excerpt":"I wrote earlier about sync waves, which order the resources inside one ArgoCD . That post ended on a caveat. Waves say nothing about ordering across…","html":"<p>I wrote earlier about <a href=\"/blog/2026-07-03-argocd-sync-waves-ordering-rollout/\">sync waves</a>, which order the resources <em>inside</em> one <a href=\"https://argo-cd.readthedocs.io/en/stable/\">ArgoCD</a> <code class=\"language-text\">Application</code>. That post ended on a caveat. Waves say nothing about ordering <em>across</em> Applications, and nothing about the case that gets tedious first: running the same app in more than one place.</p>\n<p>That case appears the moment you have a second cluster. You copy the <code class=\"language-text\">Application</code> manifest, change the destination server and the name, maybe change one value in the Helm overrides, and commit. Now there are two. Then a third region comes online, and a fourth, and every change to the base config has to be made in every copy by hand. Miss one, and that cluster drifts. This is the copy-paste problem. <a href=\"https://opengitops.dev/\">GitOps</a> (managing deployments declaratively from Git) makes it worse, because the copies all sit in Git looking authoritative while slowly disagreeing with each other.</p>\n<p>This post is for people already running ArgoCD who have several <code class=\"language-text\">Application</code> manifests that are basically the same, with the knobs set differently. That might be one app across many clusters, or many similar apps in one cluster. <a href=\"https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/\">ApplicationSets</a> are the tool for this. They have been bundled into ArgoCD itself since <a href=\"https://github.com/argoproj/argo-cd/releases/tag/v2.3.0\">version 2.3</a>, so there is nothing extra to install on a current cluster. I cover:</p>\n<ul>\n<li>what the ApplicationSet controller actually does;</li>\n<li>the generators worth knowing;</li>\n<li>a Git-driven example I would actually ship;</li>\n<li>the failure modes, one of which can delete things you did not mean to delete.</li>\n</ul>\n<h2>What an ApplicationSet is: a factory for Applications</h2>\n<p>An <code class=\"language-text\">ApplicationSet</code> is a custom resource that produces <code class=\"language-text\">Application</code> resources. It is a factory, not a deployment. It has two halves:</p>\n<ul>\n<li>a set of <strong>generators</strong>, which emit parameters;</li>\n<li>a <strong>template</strong>, which is an <code class=\"language-text\">Application</code> manifest with placeholders in it.</li>\n</ul>\n<p>The controller runs the generators, gets back a list of parameter sets, and renders the template once per set. Three parameter sets in, three Applications out. The normal ArgoCD you already run then owns and reconciles those Applications. The ApplicationSet controller only keeps the <em>existence</em> of each Application in step with what the generators say should exist.</p>\n<p><img src=\"/6b3ed98d6873769ce3d47fe91aed692b/applicationset-fanout.svg\" alt=\"One ApplicationSet file holds a generator and a template. The generator emits one parameter set per cluster, and the template is rendered once per set, producing one Application per cluster, each pointing at its own destination.\"></p>\n<p>The important shift is where the list of targets lives. Without an ApplicationSet, the list of “which clusters run this app” is implicit, spread across N hand-written files. With one, that list is <em>data</em> the generator reads, and the Applications are a pure function of it. Register a new cluster, and the cluster generator emits one more parameter set, so one more Application appears on its own. You did not write it; the controller did.</p>\n<h2>The generators, and which ones you actually need</h2>\n<p>ArgoCD ships several <a href=\"https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/Generators/\">generators</a>: List, Cluster, Git, Matrix, Merge, SCM Provider, Pull Request, and Cluster Decision Resource. You do not need all of them. In practice, three do most of the work.</p>\n<h3>List: write the parameter sets yourself</h3>\n<p>The <strong>List generator</strong> is the literal case. You write out each parameter set by hand, as in this two-cluster example:</p>\n<div class=\"gatsby-highlight\" data-language=\"yaml\"><pre class=\"language-yaml\"><code class=\"language-yaml\"><span class=\"token key atrule\">generators</span><span class=\"token punctuation\">:</span>\n  <span class=\"token punctuation\">-</span> <span class=\"token key atrule\">list</span><span class=\"token punctuation\">:</span>\n      <span class=\"token key atrule\">elements</span><span class=\"token punctuation\">:</span>\n        <span class=\"token punctuation\">-</span> <span class=\"token key atrule\">cluster</span><span class=\"token punctuation\">:</span> eu\n          <span class=\"token key atrule\">server</span><span class=\"token punctuation\">:</span> https<span class=\"token punctuation\">:</span>//eu.k8s.internal\n        <span class=\"token punctuation\">-</span> <span class=\"token key atrule\">cluster</span><span class=\"token punctuation\">:</span> us\n          <span class=\"token key atrule\">server</span><span class=\"token punctuation\">:</span> https<span class=\"token punctuation\">:</span>//us.k8s.internal</code></pre></div>\n<p>This is honest and readable, and it is the right choice when the list is short and rarely changes. Its weakness is that it is still a hand-maintained list. It does not really solve the drift problem; it moves it into one file instead of many. That is an improvement, but not the end state.</p>\n<h3>Cluster: generate from registered clusters</h3>\n<p>The <strong>Cluster generator</strong> reads the clusters ArgoCD already knows about (the ones you registered as destinations) and emits one parameter set per cluster. You can filter with label selectors, so an app lands only on clusters labelled, say, <code class=\"language-text\">region=eu</code> or <code class=\"language-text\">env=prod</code>. This generator makes “deploy to every production cluster” a property of your cluster inventory instead of a list you edit. Add a cluster with the right labels, and the app follows.</p>\n<h3>Git: generate from the repository</h3>\n<p>The <strong>Git generator</strong> reads a Git repository and generates parameters from what it finds, in one of two modes:</p>\n<ul>\n<li><em>Directory</em> mode emits one parameter set per directory matching a glob. This suits a monorepo where each subdirectory is an app or an environment.</li>\n<li><em>File</em> mode reads config files (JSON or YAML) matching a glob and pulls parameters out of each one. This is the pattern I use most, shown in the next section.</li>\n</ul>\n<p>The Git generator makes the desired state fully declarative. The repository becomes the source of truth for <em>what exists</em>, not just for how each piece is configured.</p>\n<h3>The specialised generators</h3>\n<p>The rest are for specific needs:</p>\n<ul>\n<li><strong>Matrix</strong> and <strong>Merge</strong> combine other generators. Matrix multiplies them; Merge overlays one on another.</li>\n<li><strong>SCM Provider</strong> and <strong>Pull Request</strong> talk to a code-hosting platform such as GitHub to discover repositories or open PRs. That is how you get an ephemeral preview environment per pull request.</li>\n<li><strong>Cluster Decision Resource</strong> hands the “which clusters” decision to an external controller.</li>\n</ul>\n<p>Use these when you have the specific need. The first three cover the everyday cases.</p>\n<h2>A file-per-environment setup I would actually ship</h2>\n<p>This is the pattern I trust for running one service across several environments. A directory in Git holds one small config file per environment, and a Git file generator turns each file into an Application. The layout looks like this:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">envs/\n  dev/config.json\n  staging/config.json\n  prod/config.json</code></pre></div>\n<p>Each <code class=\"language-text\">config.json</code> holds only what differs between environments. Here is the production one:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span>\n  <span class=\"token property\">\"env\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"prod\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"server\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"https://prod.k8s.internal\"</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"replicas\"</span><span class=\"token operator\">:</span> <span class=\"token number\">6</span><span class=\"token punctuation\">,</span>\n  <span class=\"token property\">\"valuesFile\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"values-prod.yaml\"</span>\n<span class=\"token punctuation\">}</span></code></pre></div>\n<p>The ApplicationSet reads those files. Its generator globs <code class=\"language-text\">envs/*/config.json</code>, and its template fills the Application name, Helm values file, and destination server from each file’s fields:</p>\n<div class=\"gatsby-highlight\" data-language=\"yaml\"><pre class=\"language-yaml\"><code class=\"language-yaml\"><span class=\"token key atrule\">apiVersion</span><span class=\"token punctuation\">:</span> argoproj.io/v1alpha1\n<span class=\"token key atrule\">kind</span><span class=\"token punctuation\">:</span> ApplicationSet\n<span class=\"token key atrule\">metadata</span><span class=\"token punctuation\">:</span>\n  <span class=\"token key atrule\">name</span><span class=\"token punctuation\">:</span> workflow<span class=\"token punctuation\">-</span>console\n  <span class=\"token key atrule\">namespace</span><span class=\"token punctuation\">:</span> argocd\n<span class=\"token key atrule\">spec</span><span class=\"token punctuation\">:</span>\n  <span class=\"token key atrule\">goTemplate</span><span class=\"token punctuation\">:</span> <span class=\"token boolean important\">true</span>\n  <span class=\"token key atrule\">generators</span><span class=\"token punctuation\">:</span>\n    <span class=\"token punctuation\">-</span> <span class=\"token key atrule\">git</span><span class=\"token punctuation\">:</span>\n        <span class=\"token key atrule\">repoURL</span><span class=\"token punctuation\">:</span> https<span class=\"token punctuation\">:</span>//github.com/example/deploy.git\n        <span class=\"token key atrule\">revision</span><span class=\"token punctuation\">:</span> HEAD\n        <span class=\"token key atrule\">files</span><span class=\"token punctuation\">:</span>\n          <span class=\"token punctuation\">-</span> <span class=\"token key atrule\">path</span><span class=\"token punctuation\">:</span> <span class=\"token string\">\"envs/*/config.json\"</span>\n  <span class=\"token key atrule\">template</span><span class=\"token punctuation\">:</span>\n    <span class=\"token key atrule\">metadata</span><span class=\"token punctuation\">:</span>\n      <span class=\"token key atrule\">name</span><span class=\"token punctuation\">:</span> <span class=\"token string\">\"workflow-console-{{.env}}\"</span>\n    <span class=\"token key atrule\">spec</span><span class=\"token punctuation\">:</span>\n      <span class=\"token key atrule\">project</span><span class=\"token punctuation\">:</span> default\n      <span class=\"token key atrule\">source</span><span class=\"token punctuation\">:</span>\n        <span class=\"token key atrule\">repoURL</span><span class=\"token punctuation\">:</span> https<span class=\"token punctuation\">:</span>//github.com/example/deploy.git\n        <span class=\"token key atrule\">targetRevision</span><span class=\"token punctuation\">:</span> HEAD\n        <span class=\"token key atrule\">path</span><span class=\"token punctuation\">:</span> charts/workflow<span class=\"token punctuation\">-</span>console\n        <span class=\"token key atrule\">helm</span><span class=\"token punctuation\">:</span>\n          <span class=\"token key atrule\">valueFiles</span><span class=\"token punctuation\">:</span>\n            <span class=\"token punctuation\">-</span> <span class=\"token string\">\"{{.valuesFile}}\"</span>\n      <span class=\"token key atrule\">destination</span><span class=\"token punctuation\">:</span>\n        <span class=\"token key atrule\">server</span><span class=\"token punctuation\">:</span> <span class=\"token string\">\"{{.server}}\"</span>\n        <span class=\"token key atrule\">namespace</span><span class=\"token punctuation\">:</span> workflow<span class=\"token punctuation\">-</span>console\n      <span class=\"token key atrule\">syncPolicy</span><span class=\"token punctuation\">:</span>\n        <span class=\"token key atrule\">automated</span><span class=\"token punctuation\">:</span>\n          <span class=\"token key atrule\">prune</span><span class=\"token punctuation\">:</span> <span class=\"token boolean important\">true</span>\n          <span class=\"token key atrule\">selfHeal</span><span class=\"token punctuation\">:</span> <span class=\"token boolean important\">true</span></code></pre></div>\n<p>Adding an environment is now a single step: create <code class=\"language-text\">envs/qa/config.json</code> and commit. The generator sees a new file and emits a new parameter set. A <code class=\"language-text\">workflow-console-qa</code> Application appears, syncs, and self-heals like the others. Nobody edited the ApplicationSet, and that is the property you are paying for.</p>\n<p>Two details in that manifest matter:</p>\n<ul>\n<li><code class=\"language-text\">goTemplate: true</code> switches the placeholders to <a href=\"https://pkg.go.dev/text/template\">Go text/template</a> syntax. Turn it on: it gives you conditionals, defaults, and functions instead of the older bare <code class=\"language-text\">{{param}}</code> substitution. On a current ArgoCD, it is the syntax to standardise on.</li>\n<li>The <code class=\"language-text\">automated</code> sync policy sits on the <em>generated</em> Application, so each environment reconciles itself. The ApplicationSet only decides which Applications exist, not whether they are in sync.</li>\n</ul>\n<h2>Matrix: powerful, and the first place people get burned</h2>\n<p>Sometimes you need the cross product of two axes, such as every app on every cluster. The <a href=\"https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/Generators-Matrix/\">Matrix generator</a> combines two child generators and emits a parameter set for each pair.</p>\n<p><img src=\"/f075238c8f886d5022f454bf867d8e15/applicationset-matrix.svg\" alt=\"A Matrix generator combines a cluster generator of three clusters with a list generator of two apps, producing six Applications: web and worker for each of eu, us, and asia. The label warns that cardinality is multiplicative.\"></p>\n<p>The trap is in the word <em>multiplies</em>. Three clusters and two apps make six Applications, which is fine. Ten clusters and ten apps make a hundred Applications from one file, and a hundred syncs the first time it reconciles.</p>\n<p>Matrix takes exactly two child generators. You get a third axis by nesting another Matrix inside one of them, and the product compounds fast. Before you apply a Matrix generator to a live controller, multiply the numbers out by hand and make sure the result is a number you meant.</p>\n<h2>Where it breaks</h2>\n<p><strong>A generator that stops emitting a parameter set deletes the Application.</strong> This surprises people, and it is the most important point in this post. By default, the controller keeps the set of Applications exactly in step with the generator output. That means it creates, updates, <em>and</em> deletes. Remove a directory the Git generator was reading, or narrow a cluster label selector, and the matching Applications are pruned. Because those Applications had their own automated sync, their live resources go with them. If the generator input hiccups and briefly returns nothing, the controller can try to delete everything at once.</p>\n<p>Two guards exist, and both are worth knowing:</p>\n<ul>\n<li>An <a href=\"https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/Application-Deletion/\">applications sync policy</a> can stop the controller from deleting. With <code class=\"language-text\">create-update</code> it only creates and updates; with <code class=\"language-text\">create-only</code> it never even updates.</li>\n<li><code class=\"language-text\">preserveResourcesOnDeletion</code> keeps the underlying workloads alive even when their Application is removed.</li>\n</ul>\n<p>On anything near production, I set the policy to non-destructive first and only loosen it once I trust the inputs.</p>\n<p><strong>A bad template change rolls out everywhere at once.</strong> The flip side of a single template is that a mistake in it lands in every generated Application simultaneously. There is no limit on the blast radius. This is where the sync waves and health checks from the <a href=\"/blog/2026-07-03-argocd-sync-waves-ordering-rollout/\">earlier post</a> matter more, not less. It is also where a canary label selector, which renders the change to one cluster before the rest, earns its place.</p>\n<p><strong>The Git generator polls; it is not instant.</strong> The generators do not watch Git in real time. The ApplicationSet controller re-runs them on an interval (three minutes by default), so a committed change takes a moment to produce a new Application. If you want it prompt, set up the <a href=\"https://argo-cd.readthedocs.io/en/stable/operator-manual/webhook/\">ArgoCD webhook</a> so that a push notifies the controller immediately. Many “my new environment did not appear” bug reports are just the poll interval.</p>\n<p><strong>ApplicationSets do not order Applications against each other.</strong> This is the same boundary from the sync-waves post. An ApplicationSet decides <em>which</em> Applications exist; it does not sequence them. If your database Application must be healthy before the app Applications sync, you need an app-of-apps setup or another dependency mechanism. A generator cannot express it.</p>\n<p><strong>AppProject and RBAC still apply.</strong> A generated Application is a normal Application, so its <a href=\"https://argo-cd.readthedocs.io/en/stable/user-guide/projects/\">AppProject</a> restrictions on source repos, destinations, and resource kinds still apply. If an ApplicationSet generates Applications that point at a cluster the project does not allow, those Applications will not sync. The error shows up on the child Application, not on the ApplicationSet. When a generated app refuses to deploy, check the project before you suspect the template.</p>\n<h2>Tradeoffs, and what I would do differently</h2>\n<p>The honest cost of an ApplicationSet is a layer of indirection. You no longer read a file and see the Application. You read a template and a generator and hold the cross product in your head. For two near-identical Applications that rarely change, that indirection is not worth it; two hand-written files are clearer. The pattern pays off once the count grows, once the list changes often, or once “we forgot to update one” has actually happened to you. Below roughly three copies, I would still write them out.</p>\n<p>What I would tell my earlier self is to start non-destructive. The first time I let a Git generator drive deletions, an input mistake removed an Application I wanted. The automated sync cleaned up its resources before I noticed. So set the sync policy to create-and-update only, get comfortable with how the generators behave on real inputs, and enable pruning deliberately once you trust it. The default is convenient, and it is also the sharpest edge in the tool.</p>\n<h2>Where this runs</h2>\n<p>This matters to me because CMS computing does not run on one cluster. The <a href=\"/project/cms-workflow-operations/\">WMCore and Unified operations stack</a> I maintained schedules Monte Carlo production and reconstruction across the <a href=\"https://wlcg.web.cern.ch/\">Worldwide LHC Computing Grid</a>, a large collection of sites rather than a single place. The operational services on top of it are exactly what you want defined once and generated per target, instead of copied and left to drift.</p>\n<p>ApplicationSets are the “which Applications exist” half of that story. <a href=\"/blog/2026-07-03-argocd-sync-waves-ordering-rollout/\">Sync waves</a> are the “in what order each one comes up” half. Together, they turn a deploy across many clusters into something I can reason about from one file, instead of something I hope stayed consistent across a dozen.</p>\n<hr>\n<p><em>Diagrams by M. Hassan Ahmed, created for this post, released under CC0 (public domain). No external image was used.</em></p>","frontmatter":{"title":"ArgoCD ApplicationSets: One Template, Many Clusters","date":"2026-08-07T00:00:00.000Z","description":"ArgoCD ApplicationSets generate one Application per cluster or environment from a single template. How the generators work, a Git example, and where they bite.","thumbnail":{"childImageSharp":{"fluid":{"base64":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAALCAIAAADwazoUAAAACXBIWXMAAAsSAAALEgHS3X78AAAB3ElEQVQoz1WP2XKjMBBFeQsIJDYh9k0SSAZjwHbGqdhJJpn8/zdNQ+Zlqm7d6mqd2602PD35uzx9dPsRJO8f6v1LvL5BIV7fwQ+f38Pnd/vyIP3gqhHInZ8MRAqblODEb2iiQWq8D6e3Xr9M82/wYXws569p+ZD65lNJE0WCFuEtZdhBtcmvnKDGtAUhv0J+iZIWl5LUnVNKi9WWW0Cf7ACQAEDEsL3S8SvwrfBK5OW2m8O4cND87db/eTT352DQCMJubuEUCuBJWIMbFsn9RKX8nItLLq60mHAoHFp5Xef1wtO7q94JqiDV8Jo0ayGucXv2UwU3ZySS0Mr4GUTLyWW9G3Msuc0r3HNb1KSTHhOsnDJ+ydpz0ixhOWEmDPiJy2TcrLm8JO2aiYsX905YYcEdiPXckTXuBA4qWowAwJoU4O66hU2cECrias3bS1qvUTbhQOD/w6TjJKhpNgLwo21zxA3TjkkhouMSjyc2nKLT7HGNwgLz1mkrrIXDaxhkB0Wgxnhe49PCpiU4TqSWhmUzOC98PtBVh7Nij9lfBxTmsBN3jTsrrDnuBYpKfz1Et4H+GsPrwbsqoqRhOsy02ROKdrEnKzLR1kFhhuLcohliOaKZ+Q/YZQMQA/MX3W1J0TzsEjEAAAAASUVORK5CYII=","aspectRatio":1.899441340782123,"src":"/static/a6a16269b53c8cca9bd20782d15167ce/40a76/hero.png","srcSet":"/static/a6a16269b53c8cca9bd20782d15167ce/c972b/hero.png 340w,\n/static/a6a16269b53c8cca9bd20782d15167ce/27625/hero.png 680w,\n/static/a6a16269b53c8cca9bd20782d15167ce/40a76/hero.png 1360w,\n/static/a6a16269b53c8cca9bd20782d15167ce/ed396/hero.png 2000w","sizes":"(max-width: 1360px) 100vw, 1360px"}}}}}},"pageContext":{"slug":"/2026-08-07-argocd-applicationsets-many-clusters/","previous":"blog/2026-08-04-fastapi-background-tasks-vs-task-queue/","next":"blog/2026-08-06-fastapi-dependency-injection-explained/"}},"staticQueryHashes":["32046230"]}