ApplicationSets: Onboarding a Workload With a Directory and a Pull Request
Generating one Application per service directory instead of hand-writing each one.
The problem
App-of-apps solves how a cluster gets bootstrapped from one root Application, but it doesn’t solve how each individual Application gets written. In an organisation with a dozen small services, each one typically needs its own near-identical Application manifest:
# apps/hello.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: hello
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/<your-username>/<your-repo>.git
targetRevision: main
path: services/hello
destination:
server: https://kubernetes.default.svc
namespace: hello
syncPolicy:
automated:
prune: true
selfHeal: true
Adding a thirteenth service means copying this file, changing three strings, and remembering to also commit it alongside the service’s own manifests — a step that lives in a different mental category from “write the Deployment”, so it’s the one that gets forgotten. When it is forgotten, the failure is silent: the service’s manifests sit in git, correctly written, and nothing ever applies them, because nothing in the cluster is watching that path yet. Nobody gets an error. The service is just missing, and the person who added it finds out only when someone asks why it isn’t running.
Copy-paste also drifts. Twelve files that started identical except for three fields
slowly diverge — one has an extra syncOptions entry another doesn’t, one still points
at an old targetRevision — because each was hand-edited independently and nothing
keeps them consistent.
Working through it
Generate the repetitive part instead of writing it by hand
The twelve Application manifests differ from each other in exactly the same three places every time: a name, a source path, a destination namespace. That’s a strong signal the manifest shouldn’t be hand-written at all — it should be generated from whatever already varies per service, which in a git-based layout is the directory structure itself.
The ApplicationSet controller turns a directory listing into Applications
ApplicationSet is a separate CRD, reconciled by a controller bundled into Argo CD’s
standard install since 2.3. It takes a generator — a source of parameters — and a
template — an Application manifest with placeholders — and produces one Application
per set of parameters the generator returns. The git directory generator’s parameters
are the paths matching a glob inside a repository:
generators:
- git:
repoURL: https://github.com/<your-username>/<your-repo>.git
revision: main
directories:
- path: services/*
Each directory under services/ that exists in the repository becomes one parameter
set, exposing {{path}} (the full path) and
{{path.basename}} (the last segment) to the template.
Adding a service becomes adding a directory
With the generator watching services/*, a service’s own Deployment and Service
manifests, committed under services/<name>/, are sufficient on their own. Nobody writes
an Application for it — the ApplicationSet controller notices the new directory on its
next poll and creates one from the template. Onboarding a new service and registering it
with Argo CD become the same pull request instead of two.
The polling interval is a real trade-off, not just a default
The git generator re-lists the repository on an interval — requeueAfterSeconds,
three minutes by default. That means a new directory doesn’t produce an Application
instantly; there’s a window, bounded by that setting, between the merge and the
Application appearing. Lowering it trades latency for load on the git host and the API
server; a webhook-driven refresh removes the wait entirely but adds a component (an
ingress and a shared secret between the git host and Argo CD) that a poll-based setup
doesn’t need. For most service counts, a short poll interval is the simpler choice and
the wait is not one anyone notices.
Templating failures are quieter than a hand-written manifest’s
A malformed kustomization.yaml inside a generated Application’s source path doesn’t
fail the same way a bad hand-written Application does — the ApplicationSet still creates
the Application object, but that Application then fails to sync, and the failure is
visible on the generated Application, not on the ApplicationSet itself. Anyone debugging
“why isn’t my service running” needs to know to check kubectl get applications, not
just kubectl get applicationsets — the generation step and the sync step fail
independently and are diagnosed in different places.
The solution
A reproducible setup on kind, using the same one small git repository from bootstrapping
the cluster with app-of-apps — the pinned Argo CD install already includes the
ApplicationSet controller, so no separate installation step is needed.
kind create cluster --name applicationset-demo
kubectl create namespace argocd
kubectl apply -n argocd -f \
https://raw.githubusercontent.com/argoproj/argo-cd/v2.12.4/manifests/install.yaml
kubectl -n argocd wait --for=condition=available --timeout=300s \
deployment/argocd-applicationset-controller
In your git repository, two service directories to start with:
# services/hello/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
# services/hello/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello
spec:
replicas: 1
selector:
matchLabels:
app: hello
template:
metadata:
labels:
app: hello
spec:
containers:
- name: hello
image: nginxdemos/hello:plain-text
ports:
- containerPort: 80
# services/hello/service.yaml
apiVersion: v1
kind: Service
metadata:
name: hello
spec:
selector:
app: hello
ports:
- port: 80
services/world/ is an identical set of three files with every hello replaced by
world. And the ApplicationSet itself:
# applicationset.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: services
namespace: argocd
spec:
goTemplate: true
goTemplateOptions: ["missingkey=error"]
generators:
- git:
repoURL: https://github.com/<your-username>/<your-repo>.git
revision: main
requeueAfterSeconds: 30
directories:
- path: services/*
template:
metadata:
name: "{{.path.basename}}"
spec:
project: default
source:
repoURL: https://github.com/<your-username>/<your-repo>.git
targetRevision: main
path: "{{.path.path}}"
destination:
server: https://kubernetes.default.svc
namespace: "{{.path.basename}}"
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Commit and push services/hello/, services/world/, and applicationset.yaml, then
apply the ApplicationSet:
kubectl apply -f applicationset.yaml
kubectl get applications -n argocd
Correct output, once the generator has run:
NAME SYNC STATUS HEALTH STATUS
hello Synced Healthy
world Synced Healthy
Now add a third service without touching the ApplicationSet at all — push
services/again/ with the same three files, again substituted for hello:
git add services/again && git commit -m "add again service" && git push
sleep 35 # requeueAfterSeconds is 30 in this example
kubectl get applications -n argocd
NAME SYNC STATUS HEALTH STATUS
again Synced Healthy
hello Synced Healthy
world Synced Healthy
No Application manifest was written for again. The only thing committed was the
service’s own files, in the same directory shape every other service already uses.
Conclusion
The manual Application-per-service approach and ApplicationSets produce the same running cluster. What differs is where the repetitive, error-prone step lives: in a human’s memory, or in a generator that runs the same way every time.
Generate what varies predictably; hand-write what doesn’t. Twelve Applications differing only in name, path and namespace are a template and a parameter list, not twelve documents. Reach for ApplicationSets specifically where the variation is that mechanical — a workload with genuinely bespoke sync behaviour still deserves its own hand-written Application.
The convention the generator relies on has to be enforced somewhere. A directory
generator matching services/* only works because every service’s manifests live at
that consistent path with that consistent shape; nothing stops someone from adding a
service directory that doesn’t match the assumed layout, and when that happens the
Application it produces is broken in a way that’s specific to whatever assumption broke.
Two failure surfaces now exist where hand-writing had one. Debugging a missing or broken service means checking both the ApplicationSet (did a directory get picked up at all) and the generated Application (did what got picked up actually sync) — worth knowing before the first incident, not during it. </content>