The problem
You now have payments-dev and payments-prod, each created with a hand-typed argocd app create. That command is not in git - so the Applications themselves are exactly the kind of unreviewed, unrecorded cluster state GitOps is meant to remove. And with 40 services x 3 environments, nobody wants to write 120 Applications by hand. Also, in a shared cluster, the payments team must not be able to deploy into someone else's namespace.
What you need to know already: 26.4 (the Application object), 26.9 (prune, waves, the resources finalizer), 17.30 (RBAC: roles and bindings), 15.26 (namespaces, labels), 2.16 (timers and cron schedules, for the cron format at the end).
Applications are just manifests
An Application is a Kubernetes object, so it can be a YAML file in git - and another Application can deploy it. The app-of-apps pattern:
payments-gitops/
apps/ <- the root app's path: nothing but Application / AppProject manifests
project.yaml AppProject payments (sync-wave -1: before the apps that use it)
payments-dev.yaml Application -> overlays/dev
payments-prod.yaml Application -> overlays/prod
overlays/...
root.yaml the ONE thing you apply by hand: Application payments-root -> apps/
The root app (payments-root) points at apps/; its "manifests" are the child Applications. kubectl apply -f root.yaml once - the bootstrap, the single manual step that starts everything - and from then on adding an environment or a service is a commit to apps/.
With prune: true on the root, deleting a child's file deletes the child Application - and if the child has the resources finalizer (26.9), everything it deployed. Treat apps/ with the same review rules as prod.
Waves (26.9) work between Applications too: give the AppProject sync-wave: "-1", platform apps (the ingress controller from 16.19, cert-manager from 26.12) "0", the services "1". Since Argo CD 1.8 a child's health does not count toward its parent's health by default; teams that need "wait until the child is healthy" add a health check for argoproj.io/Application in argocd-cm.
ApplicationSet
When the Applications all follow one pattern, write the pattern once. An ApplicationSet is an object that generates Applications from a template:
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: payments
namespace: argocd
spec:
goTemplate: true # {{ }} placeholders in Go template syntax (25.24)
generators:
- git: # one app per directory that matches
repoURL: https://git.lab/pay/payments-gitops.git
revision: HEAD
directories:
- path: overlays/*
template: # an Application, with placeholders
metadata:
name: 'payments-{{.path.basename}}' # basename = last part of the path: dev, prod
spec:
project: payments
source:
repoURL: https://git.lab/pay/payments-gitops.git
targetRevision: HEAD
path: '{{.path.path}}' # overlays/dev, overlays/prod
destination:
server: https://kubernetes.default.svc
namespace: 'pay-{{.path.basename}}'
syncPolicy:
automated: { prune: true, selfHeal: true }
A generator produces a list of parameter sets; the template is filled once per set. The kinds:
- list - you write the elements yourself.
- clusters - every cluster registered in Argo CD, filtered by label ("deploy the log agent everywhere").
- git - one per directory, or per file, in a repo (above).
- matrix / merge - combine two generators (every env x every cluster).
- pull request - a preview environment per open pull request, deleted when it closes.
- SCM provider - every repo in a GitHub/GitLab organisation (SCM = source code management, the git hosting service).
Rule of thumb: app-of-apps when each app is hand-written and different, ApplicationSet when they follow a pattern.
AppProject: the tenancy boundary
A tenant is one team sharing a cluster with others. An AppProject is Argo CD's fence around a tenant's Applications: which repos they may use, where they may deploy, which kinds of objects, and who may press which buttons.
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: payments
namespace: argocd
spec:
sourceRepos: [https://git.lab/pay/payments-gitops.git] # where manifests may come FROM
destinations: # where they may go TO
- server: https://kubernetes.default.svc
namespace: pay-*
clusterResourceWhitelist: [] # no cluster-scoped objects (no ClusterRoles, no CRDs)
namespaceResourceBlacklist: # kinds they may NOT create even in their namespaces
- group: ''
kind: ResourceQuota # the platform owns quotas (17.9)
roles: # Argo CD permissions, for groups from single sign-on
- name: deployer
policies: [p, proj:payments:deployer, applications, sync, payments/*, allow]
groups: [payments-team]
syncWindows:
- kind: deny # no automated syncs during the Friday freeze
schedule: '0 16 * * 5' # cron format: minute hour day month weekday -> Fri 16:00
duration: 64h # until Monday 08:00
applications: ['*-prod']
The policy line reads: policy, for role proj:payments:deployer, on applications, action sync, objects payments/* (every app in the project), allow. A sync window is a time range when syncs are allowed or denied - a change freeze written as config.
An Application that points outside its project is refused with a condition (an error recorded in its status), not synced:
InvalidSpecError: application destination server 'https://kubernetes.default.svc' and namespace 'kube-system' do not match any of the allowed destinations in project 'payments'
The default project allows everything - fine for a lab, an audit finding in a bank. Argo CD-wide permissions live in argocd-rbac-cm (policy.csv with lines like the one above, and policy.default: role:readonly for everyone else); users and single sign-on in argocd-cm. All of that, and the root apps, can themselves live in a platform repo managed by an Argo CD Application: "Argo CD manages Argo CD".
What you can now do
- Bootstrap a set of Applications from git with one root app.
- Generate Applications from a pattern with an ApplicationSet.
- Fence a team in with an AppProject and read the error when an app breaks the fence.