ApplicationSet - Auto-Discover Services from Git#
What We Built#
A eu-dev environment that runs on the same Rancher Desktop cluster as dev,
deploying into namespace alpha-dev. The difference is in how ArgoCD Applications
are created:
| Environment | How Applications are created | Namespace |
|---|---|---|
dev | One Application YAML per service, applied manually with kubectl | dev |
eu-dev | One ApplicationSet, applied once; services auto-discovered from values files | alpha-dev |
Both use the same charts/backend-service/ Helm chart. dev is kept as a
reference for the manual pattern.
Note:
alphais a placeholder team name. Replace it with your actual team identifier. It drives namespace naming (alpha-dev,alpha-staging,alpha-prod) and ArgoCD Application name prefixes (alpha-svc1-eu-dev, etc.).
Naming Convention#
In a real company, deployments have four dimensions: team, region, environment, and service. Each maps to a different artifact:
| Dimension | Where it lives | Example |
|---|---|---|
| Team | Namespace, Application name prefix | alpha |
| Region | Folder name, cluster identifier | eu |
| Environment | Folder name, namespace suffix | dev |
| Service | Values filename, Application name | svc1 |
Putting it together:
| Artifact | Pattern | Example |
|---|---|---|
| Folder | environments/<region>-<env>/ | environments/eu-dev/ |
| k8s namespace | <team>-<env> | alpha-dev |
| Application name | <team>-<svc>-<region>-<env> | alpha-svc1-eu-dev |
| ApplicationSet name | <team>-<region>-<env>-services | alpha-eu-dev-services |
Why region is in the Application name (learning setup only):
In a real multi-region company, each region has its own cluster and its own ArgoCD
instance - alpha-svc1-dev on the EU cluster never collides with alpha-svc1-dev on
the NA cluster. Region would not appear in the Application name at all. In this
learning setup, one Rancher Desktop cluster hosts everything under one shared ArgoCD
instance, so region must be in the name to avoid collisions. The -eu-dev suffix is
a single-cluster workaround, not a convention to carry into production.
Why region is not in the namespace:
A namespace scopes resources within a cluster. The cluster already is the region
boundary - alpha-dev on the eu-dev cluster is inherently EU. Adding region to the
namespace (alpha-eu-dev) would be redundant in a real setup.
How the Git File Generator Works#
The ApplicationSet controller watches a path pattern in your git repo.
Every file it finds becomes one set of template parameters, and ArgoCD stamps out
one Application per file:
| |
The generator provides two things per file:
- Path variables -
{{path}}is the full file path; used to reference the values file in Helm’svalueFiles - Content variables - top-level YAML keys become template parameters;
nameOverride: svc1in the values file →{{nameOverride}}in the template
Why Git File Generator over Matrix Generator#
The matrix generator cross-products environments × services. It assumes every
service exists in every environment. If svc4 is approved for eu-dev but not
eu-staging yet, the matrix generator either errors or creates a broken Application
where no values file exists.
With the git file generator, alpha-svc4-eu-staging simply never gets created
because there is no environments/eu-staging/values/svc4.yaml. Promotion = adding
a file via PR.
Structure#
| |
Step 1 - Create values files for eu-dev#
environments/eu-dev/values/svc1.yaml:
| |
Repeat for svc2.yaml and svc3.yaml - only nameOverride, fullnameOverride,
image.tag, and host change per service.
Step 2 - Create the ApplicationSet manifest#
environments/eu-dev/appset.yaml:
| |
How the key fields resolve for svc1:
| Field | Template | Resolved value |
|---|---|---|
name | alpha-{{nameOverride}}-eu-dev | alpha-svc1-eu-dev |
files path | environments/eu-dev/values/*.yaml | discovers svc1, svc2, svc3 |
valueFiles | $values/{{path}} | $values/environments/eu-dev/values/svc1.yaml |
namespace | alpha-dev | alpha-dev |
Step 3 - Add /etc/hosts entries#
| |
Step 4 - Commit and push#
| |
Step 5 - Apply the ApplicationSet#
This is the only kubectl apply needed for the entire eu-dev environment:
| |
Watch ArgoCD generate the Applications:
| |
Wait for all three to reach Synced / Healthy.
Step 6 - Verify in the browser#
| |
Step 7 - Test auto-discovery: add svc4#
No ApplicationSet edits, no kubectl apply.
| |
| |
alpha-svc4-eu-dev appears automatically. Force immediate refresh if needed:
| |
Promoting a Service to eu-staging#
Apply the staging ApplicationSet once on the staging cluster:
| |
environments/eu-staging/appset.yaml scans environments/eu-staging/values/*.yaml.
Add svc4 to staging by PR:
| |
svc4 stays eu-dev only until that file exists. Git PR is the promotion gate.
Key Concepts#
Application naming - shared vs per-region ArgoCD#
ArgoCD Application names are unique within an ArgoCD instance - they are CRDs in
the argocd namespace. Two Applications with the same name on the same instance
conflict.
Per-region clusters (real-world setup)
Each region has its own k8s cluster and its own ArgoCD instance. The two instances never share a namespace, so names never collide:
| |
Region is implicit - you’re already connected to the right cluster when you see the Application. It does not belong in the Application name. The naming simplifies to:
| Artifact | Pattern | Example |
|---|---|---|
| Folder | environments/<region>-<env>/ | environments/eu-dev/ |
| k8s namespace | <team>-<env> | alpha-dev |
| Application name | <team>-<svc>-<env> | alpha-svc1-dev |
| ApplicationSet name | <team>-<env>-services | alpha-dev-services |
The folder still carries the region because the git repo is shared across regions and each cluster’s ArgoCD needs to point at its own folder. That’s a git organisation concern, not an ArgoCD naming concern.
Single shared cluster (this learning setup)
One Rancher Desktop cluster runs everything under one ArgoCD instance. Region must appear in the Application name to keep names unique across simulated environments:
| |
The -eu-dev suffix in our Application names is a workaround for this constraint -
not a convention you would carry into a proper multi-cluster company setup.
Services with different Helm charts#
The ApplicationSet template hardcodes path: charts/backend-service, so all
discovered services share the same chart. When a service genuinely needs a
different chart (e.g. a frontend that has a different template structure), the
clean solution is one ApplicationSet per chart type, each scanning its own
subfolder.
Folder structure:
| |
appset-backend.yaml - unchanged except the scan path:
| |
appset-frontend.yaml - same structure, different chart and scan path:
| |
Benefits of this approach:
- Values files stay pure Helm - no ArgoCD-specific fields mixed in
- Each ApplicationSet is self-contained and easy to reason about
- Adding a new backend service = drop a file in
backend/values/ - Adding a new frontend service = drop a file in
frontend/values/ - Adding a third chart type = add
appset-<type>.yaml+<type>/values/subfolder
When not to split charts: if two services only differ in configuration (replica count, env vars, resource limits) but share the same template structure, extend the existing chart with feature flags rather than creating a new chart. A new chart is warranted when the template structure itself is fundamentally different.
What happens when you delete a values file#
With prune: true in the template’s syncPolicy, ArgoCD deletes the Application
(and all deployed resources) when the values file is removed from git.
To take down a service: delete its values file via PR.
ApplicationSet vs App-of-Apps#
| App-of-Apps | ApplicationSet | |
|---|---|---|
| Dev team writes | Full Application YAML | Just a values file |
| Platform controls sync policy? | No - dev controls it | Yes - template is fixed |
| Auto-discovers new services | No | Yes |
| Onboarding a new service | Write Application YAML + commit | Drop a values file + commit |
ApplicationSet controller#
Bundled with ArgoCD since v2.3. Runs as argocd-applicationset-controller in the
argocd namespace - no separate install needed.
$values reference requires ArgoCD ≥ 2.6#
The multi-source feature (ref: values) is what makes $values/{{path}} work.
Check: argocd version.
Best Practices#
1. Always label generated Applications#
Stamp team, env, and region labels onto every generated Application. Without
labels, filtering in the ArgoCD UI and argocd app list requires grep-ing by name.
With labels, you can filter an entire team or environment at once.
| |
This is already applied in environments/eu-dev/appset.yaml.
2. applicationsSync - choose based on your generator type#
spec.syncPolicy.applicationsSync controls what the ApplicationSet controller is
allowed to do with generated Applications:
| Value | Create | Update | Delete |
|---|---|---|---|
| (omitted - default) | ✓ | ✓ | ✓ |
create-only | ✓ | ✗ | ✗ |
create-update | ✓ | ✓ | ✗ |
create-delete | ✓ | ✗ | ✓ |
For the git file generator (our setup): omit applicationsSync and keep the default.
The git file generator already gates deletion behind a deliberate act - someone must
delete a values file via PR. Auto-deletion is safe and desirable here: the PR is the
human approval step. create-update would just add friction without adding safety.
When create-update does make sense: matrix generators, where the cross-product
of environments × services could accidentally delete Applications if one dimension
is misconfigured. In that case, disabling auto-delete until you’re confident is
reasonable.
3. Keep values files pure Helm#
Values files are passed directly to Helm - ArgoCD never reads them for its own
configuration. Only Helm values belong there (replicaCount, image, ingress,
resources, etc.).
ArgoCD-level concerns (sync policy, project, sync waves, notification subscriptions) belong in the ApplicationSet template - not in values files. Mixing them couples your Helm chart to ArgoCD and makes the values files unportable.
Our values files are clean. This is a pattern to preserve as the repo grows.
4. goTemplate: true for logic in templates#
The default {{}} syntax is basic string substitution. Enable Go templates when you
need conditionals, defaults, or string functions:
| |
missingkey=error is important. Without it, a missing key silently renders as
<no value>, producing broken Application names like alpha--eu-dev with no error.
With it, the ApplicationSet fails fast and clearly.
Practical use cases:
| |
Note: switching from default {{}} to Go templates changes the syntax for all
template variables. Do it early, before the template grows large.
5. templatePatch for per-Application ArgoCD overrides (ArgoCD ≥ 2.9)#
Values files handle Helm-level differences. templatePatch handles ArgoCD-level
differences - fields that Helm never sees.
When you need it: most apps share a template but one or two need an exception in ArgoCD’s own config:
- Sync waves -
svc1must deploy beforesvc2; addsync-wave: "2"to svc2 only - Manual sync - one legacy service should never auto-sync; set
automated: null - Project assignment - most in
default, one sensitive service inrestricted - Notification subscriptions - one service alerts Slack, others don’t
Without templatePatch, each exception requires a separate ApplicationSet. With it,
you add a field to that service’s values file (e.g. syncWave: "2") and patch it
into the template:
| |
This keeps one ApplicationSet instead of forking it per exception.
6. preserveResourcesOnDeletion - not recommended as a default#
When preserveResourcesOnDeletion: true is set on an ApplicationSet, deleting the
ApplicationSet leaves generated Applications (and their workloads) running - orphaned
but alive.
When it makes sense:
- Migrating an ApplicationSet to a new name/structure - old workloads stay up while you transition
- Blue-green environment switches - verify the new appset before tearing down the old
Why it’s not a safe default: If the ApplicationSet is accidentally deleted, workloads keep running but are now fully unmanaged. No GitOps reconciliation, no pruning, no ArgoCD visibility. That unmanaged state is harder to recover from than a clean deletion. For a learning setup and for most production environments, the default (delete cascades) is correct.
Gotchas#
{{nameOverride}}undefined - every values file underenvironments/eu-dev/values/must havenameOverrideset. If missing, the Application name becomesalpha--eu-dev.{{path}}in ArgoCD v3 is the directory, not the full file path - in ArgoCD v3, the git file generator’s{{path}}resolves to the directory containing the file (e.g.environments/eu-dev/values), and{{path.basename}}resolves to the last directory segment (values) - not the filename. Using$values/{{path}}or$values/{{path}}/{{path.basename}}invalueFileswill both fail. The reliable fix: construct the path from the file content instead:1 2valueFiles: - $values/environments/eu-dev/values/{{nameOverride}}.yamlThis works because
{{nameOverride}}is read from the values file content and matches the filename by convention (svc1.yamlhasnameOverride: svc1).Poll delay - ArgoCD polls git every 3 minutes by default. Force an immediate re-scan with the
argocd.argoproj.io/refreshannotation on the ApplicationSet.Deleting the ApplicationSet deletes all generated Applications - and with
prune: true, all deployed workloads too. Don’t delete it unless you intend to tear down the environment.ApplicationSet requires
kubectl apply- the ArgoCD UI “New App” button only createsApplicationCRDs. The ApplicationSet itself can be git-managed via App-of-Apps if you want the bootstrap step to also be declarative.Editing
appset.yamlin git does NOT auto-update the cluster (foreu-dev) - theeu-devApplicationSet is applied manually and is not itself managed by ArgoCD. Committing a change toenvironments/eu-dev/appset.yamland pushing will do nothing until you runkubectl apply -f environments/eu-dev/appset.yamlagain. The Applications it generates are git-driven; the ApplicationSet itself is not.eu-dev-rancheris different - the ApplicationSet lives inplatform/appset.yamland is managed by the root Application as wave 4. Changes to that file are applied automatically by ArgoCD on the next sync. This is the fully GitOps approach.