Skip to main content
  1. Posts/
  2. Learning ArgoCD/
  3. Core Patterns/

ApplicationSet - Auto-Discover Services from Git

·2741 words·13 mins
Ravi Singh
Author
Ravi Singh
Software engineer with 15+ years building backend systems and cloud platforms across fintech, automotive, and academia. I write about the things I build, debug, and learn — so I don’t forget them.
Table of Contents
Learning ArgoCD - This article is part of a series.
Part 2: This Article

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:

EnvironmentHow Applications are createdNamespace
devOne Application YAML per service, applied manually with kubectldev
eu-devOne ApplicationSet, applied once; services auto-discovered from values filesalpha-dev

Both use the same charts/backend-service/ Helm chart. dev is kept as a reference for the manual pattern.

Note: alpha is 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:

DimensionWhere it livesExample
TeamNamespace, Application name prefixalpha
RegionFolder name, cluster identifiereu
EnvironmentFolder name, namespace suffixdev
ServiceValues filename, Application namesvc1

Putting it together:

ArtifactPatternExample
Folderenvironments/<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>-servicesalpha-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:

1
2
3
environments/eu-dev/values/svc1.yaml  →  Application: alpha-svc1-eu-dev
environments/eu-dev/values/svc2.yaml  →  Application: alpha-svc2-eu-dev
environments/eu-dev/values/svc3.yaml  →  Application: alpha-svc3-eu-dev

The generator provides two things per file:

  • Path variables - {{path}} is the full file path; used to reference the values file in Helm’s valueFiles
  • Content variables - top-level YAML keys become template parameters; nameOverride: svc1 in 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
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
environments/
  dev/                           ← learning exercise: manual Application pattern
    bootstrap.yaml               ← root Application (kubectl apply once)
    apps/
      svc1.yaml
      svc2.yaml
      svc3.yaml
    values/
      svc1.yaml
      svc2.yaml
      svc3.yaml

  eu-dev/                        ← learning exercise: ApplicationSet applied manually
    appset.yaml                  ← one ApplicationSet, applied once via kubectl
    values/
      svc1.yaml                  ← drop a file here → auto-creates alpha-svc1-eu-dev
      svc2.yaml
      svc3.yaml

  eu-dev-rancher/                ← current: ApplicationSet is git-managed by ArgoCD
    platform/
      appset.yaml                ← wave 4; ArgoCD owns and reconciles this file
    services/
      svc1.yaml                  ← drop a file here → auto-creates alpha-svc1-eu-dev-rancher
      svc2.yaml
                                 ← no svc3.yaml = svc3 not deployed here yet

  eu-staging/                    ← future: EU staging cluster
    platform/
      appset.yaml
    services/
      svc1.yaml
                                 ← no svc2.yaml = svc2 not promoted to staging yet

Step 1 - Create values files for eu-dev
#

environments/eu-dev/values/svc1.yaml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
nameOverride: svc1
fullnameOverride: svc1

replicaCount: 1

image:
  repository: nginx
  tag: "1.27"

ingress:
  enabled: true
  className: traefik
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: web
  hosts:
    - host: svc1.eu-dev.ravikrs.local
      paths:
        - path: /
          pathType: Prefix

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: alpha-eu-dev-services
  namespace: argocd
spec:
  generators:
    - git:
        repoURL: https://github.com/ravikrs/learning-argocd
        revision: HEAD
        files:
          - path: "environments/eu-dev/values/*.yaml"
  template:
    metadata:
      name: 'alpha-{{nameOverride}}-eu-dev'
    spec:
      project: default
      sources:
        - repoURL: https://github.com/ravikrs/learning-argocd
          targetRevision: HEAD
          path: charts/backend-service
          helm:
            valueFiles:
              - $values/{{path}}
        - repoURL: https://github.com/ravikrs/learning-argocd
          targetRevision: HEAD
          ref: values
      destination:
        server: https://kubernetes.default.svc
        namespace: alpha-dev
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true

How the key fields resolve for svc1:

FieldTemplateResolved value
namealpha-{{nameOverride}}-eu-devalpha-svc1-eu-dev
files pathenvironments/eu-dev/values/*.yamldiscovers svc1, svc2, svc3
valueFiles$values/{{path}}$values/environments/eu-dev/values/svc1.yaml
namespacealpha-devalpha-dev

Step 3 - Add /etc/hosts entries
#

1
2
3
4
5
sudo sh -c '
echo "127.0.0.1 svc1.eu-dev.ravikrs.local" >> /etc/hosts
echo "127.0.0.1 svc2.eu-dev.ravikrs.local" >> /etc/hosts
echo "127.0.0.1 svc3.eu-dev.ravikrs.local" >> /etc/hosts
'

Step 4 - Commit and push
#

1
2
3
git add environments/eu-dev/
git commit -m "add eu-dev environment (alpha team, ApplicationSet pattern)"
git push

Step 5 - Apply the ApplicationSet
#

This is the only kubectl apply needed for the entire eu-dev environment:

1
kubectl apply -f environments/eu-dev/appset.yaml

Watch ArgoCD generate the Applications:

1
2
3
kubectl get applicationset -n argocd
argocd app list
argocd app get alpha-svc1-eu-dev

Wait for all three to reach Synced / Healthy.


Step 6 - Verify in the browser
#

1
2
3
http://svc1.eu-dev.ravikrs.local
http://svc2.eu-dev.ravikrs.local
http://svc3.eu-dev.ravikrs.local

Step 7 - Test auto-discovery: add svc4
#

No ApplicationSet edits, no kubectl apply.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# environments/eu-dev/values/svc4.yaml
nameOverride: svc4
fullnameOverride: svc4

replicaCount: 1

image:
  repository: nginx
  tag: "1.27"

ingress:
  enabled: true
  className: traefik
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: web
  hosts:
    - host: svc4.eu-dev.ravikrs.local
      paths:
        - path: /
          pathType: Prefix
1
2
3
4
5
sudo sh -c 'echo "127.0.0.1 svc4.eu-dev.ravikrs.local" >> /etc/hosts'

git add environments/eu-dev/values/svc4.yaml
git commit -m "add svc4 to eu-dev"
git push

alpha-svc4-eu-dev appears automatically. Force immediate refresh if needed:

1
2
kubectl annotate applicationset alpha-eu-dev-services -n argocd \
  argocd.argoproj.io/refresh=normal --overwrite

Promoting a Service to eu-staging
#

Apply the staging ApplicationSet once on the staging cluster:

1
kubectl apply -f environments/eu-staging/appset.yaml

environments/eu-staging/appset.yaml scans environments/eu-staging/values/*.yaml. Add svc4 to staging by PR:

1
2
Add: environments/eu-staging/values/svc4.yaml
Review + merge → alpha-svc4-eu-staging appears automatically

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:

1
2
eu-dev cluster  → ArgoCD instance A → Application: alpha-svc1-dev
na-dev cluster  → ArgoCD instance B → Application: alpha-svc1-dev  ← no conflict

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:

ArtifactPatternExample
Folderenvironments/<region>-<env>/environments/eu-dev/
k8s namespace<team>-<env>alpha-dev
Application name<team>-<svc>-<env>alpha-svc1-dev
ApplicationSet name<team>-<env>-servicesalpha-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:

1
2
Rancher Desktop → single ArgoCD → alpha-svc1-eu-dev
                                 → alpha-svc1-na-dev  ← distinct names required

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
environments/eu-dev/
  appset-backend.yaml        ← chart: charts/backend-service
  appset-frontend.yaml       ← chart: charts/frontend-service
  backend/
    values/
      svc1.yaml
      svc2.yaml
      svc3.yaml
  frontend/
    values/
      svc5.yaml

appset-backend.yaml - unchanged except the scan path:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
generators:
  - git:
      files:
        - path: "environments/eu-dev/backend/values/*.yaml"
template:
  metadata:
    name: 'alpha-{{nameOverride}}-eu-dev'
  spec:
    sources:
      - path: charts/backend-service
        ...

appset-frontend.yaml - same structure, different chart and scan path:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
generators:
  - git:
      files:
        - path: "environments/eu-dev/frontend/values/*.yaml"
template:
  metadata:
    name: 'alpha-{{nameOverride}}-eu-dev'
  spec:
    sources:
      - path: charts/frontend-service
        ...

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-AppsApplicationSet
Dev team writesFull Application YAMLJust a values file
Platform controls sync policy?No - dev controls itYes - template is fixed
Auto-discovers new servicesNoYes
Onboarding a new serviceWrite Application YAML + commitDrop 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.

1
2
3
4
5
6
7
template:
  metadata:
    name: 'alpha-{{nameOverride}}-eu-dev'
    labels:
      team: alpha
      env: dev
      region: eu

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:

ValueCreateUpdateDelete
(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:

1
2
3
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]   # fail loudly if a key is missing

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Build namespace from values file fields
namespace: '{{.team}}-{{.env}}'

# Per-region hostname (region comes from a field in the values file)
host: '{{.nameOverride}}.{{.region}}.ravikrs.local'

# Safe default if a field is optional
replicaCount: '{{.replicaCount | default "1"}}'

# Upper-case for an environment variable name
- name: SERVICE_NAME
  value: '{{.nameOverride | upper}}'

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 - svc1 must deploy before svc2; add sync-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 in restricted
  • 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:

1
2
3
4
5
spec:
  templatePatch: |
    metadata:
      annotations:
        argocd.argoproj.io/sync-wave: "{{.syncWave | default "0"}}"

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 under environments/eu-dev/values/ must have nameOverride set. If missing, the Application name becomes alpha--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}} in valueFiles will both fail. The reliable fix: construct the path from the file content instead:

    1
    2
    
    valueFiles:
      - $values/environments/eu-dev/values/{{nameOverride}}.yaml

    This works because {{nameOverride}} is read from the values file content and matches the filename by convention (svc1.yaml has nameOverride: svc1).

  • Poll delay - ArgoCD polls git every 3 minutes by default. Force an immediate re-scan with the argocd.argoproj.io/refresh annotation 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 creates Application CRDs. The ApplicationSet itself can be git-managed via App-of-Apps if you want the bootstrap step to also be declarative.

  • Editing appset.yaml in git does NOT auto-update the cluster (for eu-dev) - the eu-dev ApplicationSet is applied manually and is not itself managed by ArgoCD. Committing a change to environments/eu-dev/appset.yaml and pushing will do nothing until you run kubectl apply -f environments/eu-dev/appset.yaml again. The Applications it generates are git-driven; the ApplicationSet itself is not.

    eu-dev-rancher is different - the ApplicationSet lives in platform/appset.yaml and 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.

Learning ArgoCD - This article is part of a series.
Part 2: This Article