Running one OpenShift cluster with Argo CD is easy. Running thirty — three environments, four regions, two tenancy models — is a different problem. At that scale, the bottleneck is never the sync engine: it is the number of Application objects a human has to author, review and keep consistent. Thirty clusters times fifteen platform components is 450 Applications. Nobody maintains that by hand.
This article describes a working topology: a single root Application (app-of-apps) bootstrapping a set of ApplicationSet resources, cluster secrets carrying environment metadata as labels, and Kustomize overlays resolving the per-environment delta. Everything is declarative, everything is in Git, and adding a cluster is a one-line label change.
Context: why one Application per cluster breaks down
The naive pattern is a directory of Applications:
apps/ prd-eu-01-logging.yaml prd-eu-01-monitoring.yaml prd-eu-02-logging.yaml ... This fails for three reasons.
Drift by copy-paste. Each file duplicates the same 25 lines. A fix to syncPolicy or to ignoreDifferences lands in 12 files and misses 18. Reviewers cannot diff 450 near-identical manifests meaningfully.
No lifecycle for clusters. Decommissioning a cluster means finding and deleting every Application referencing it. Forgotten Applications stay Unknown forever and pollute health dashboards and alerting.
No rollout control. You want dev first, then stg, then prd in batches of two. Static Applications with automated.selfHeal sync everything simultaneously the moment a commit lands on main.
ApplicationSet solves all three: it is a controller that generates and reconciles Applications from a generator (clusters, git, matrix, merge), owns their full lifecycle, and supports progressive rollout strategies.
Foundation: OpenShift GitOps and cluster registration
Start from the operator-managed instance. Verify what you are running — generator and strategy support varies significantly across versions:
oc get csv -n openshift-gitops -o custom-columns=NAME:.metadata.name,VERSION:.spec.version oc -n openshift-gitops exec deploy/openshift-gitops-server -- argocd version --short Cluster secrets are your inventory
Argo CD stores remote clusters as Secret objects labeled argocd.argoproj.io/secret-type: cluster. The labels on that secret are the metadata the cluster generator will expose to templates. Treat them as your source of truth.
ARGOCD_HOST=$(oc get route openshift-gitops-server -n openshift-gitops -o jsonpath='{.spec.host}') argocd login "$ARGOCD_HOST" --sso --grpc-web # kubeconfig context must already exist locally argocd cluster add prd-eu-01-admin \ --name prd-eu-01 \ --label env=prd --label region=eu --label tier=platinum --label wave=1 \ --service-account argocd-manager \ --system-namespace openshift-gitops-agent \ --grpc-web --yes For reproducibility, prefer declaring the secret in Git with the token injected by External Secrets Operator or Sealed Secrets:
apiVersion: v1 kind: Secret metadata: name: cluster-prd-eu-01 namespace: openshift-gitops labels: argocd.argoproj.io/secret-type: cluster env: prd region: eu tier: platinum type: Opaque stringData: name: prd-eu-01 server: https://api.prd-eu-01.example.com:6443 config: | { "bearerToken": "<injected-by-eso>", "tlsClientConfig": { "insecure": false, "caData": "<base64-cluster-ca>" } } Verify the inventory is complete before building anything on top of it:
oc get secrets -n openshift-gitops -l argocd.argoproj.io/secret-type=cluster \ -o custom-columns=CLUSTER:.metadata.name,ENV:.metadata.labels.env,REGION:.metadata.labels.region argocd cluster list Repository layout
Two repositories: one for the GitOps control plane, one (or several) for workloads. The control-plane repo:
gitops-platform/ ├── bootstrap/ │ ├── root-application.yaml # applied once, by hand or by the operator │ └── appsets/ │ ├── platform-components.yaml │ └── tenant-namespaces.yaml ├── projects/ │ ├── platform.yaml │ └── tenants.yaml └── components/ ├── cluster-logging/ │ ├── base/ │ │ ├── kustomization.yaml │ │ ├── namespace.yaml │ │ ├── operatorgroup.yaml │ │ ├── subscription.yaml │ │ └── clusterlogging.yaml │ └── overlays/ │ ├── dev/kustomization.yaml │ ├── stg/kustomization.yaml │ └── prd/kustomization.yaml ├── compliance-operator/ └── network-policies/ The contract is strict: every component exposes overlays/<env> for every env label used on cluster secrets. A missing overlay must fail loudly, not silently fall back to base.
The root Application (app-of-apps)
One object bootstraps everything. It is the only manifest you ever apply imperatively.
apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: root namespace: openshift-gitops annotations: argocd.argoproj.io/sync-wave: "-10" finalizers: - resources-finalizer.argocd.argoproj.io spec: project: default source: repoURL: https://github.com/acme/gitops-platform.git targetRevision: main path: bootstrap directory: recurse: true include: '{appsets/*.yaml,../projects/*.yaml}' destination: server: https://kubernetes.default.svc namespace: openshift-gitops syncPolicy: automated: prune: true selfHeal: true syncOptions: - ApplyOutOfSyncOnly=true - ServerSideApply=true oc apply -f bootstrap/root-application.yaml argocd app get root --grpc-web From here, adding an ApplicationSet is a pull request against bootstrap/appsets/.
ApplicationSet with a matrix generator
The cluster generator alone gives you one Application per cluster. Combined with a git directory generator in a matrix, you get one Application per (cluster, component) pair — the 450 objects, generated from 40 lines.
apiVersion: argoproj.io/v1alpha1 kind: ApplicationSet metadata: name: platform-components namespace: openshift-gitops spec: goTemplate: true goTemplateOptions: ["missingkey=error"] generators: - matrix: generators: - clusters: selector: matchExpressions: - key: env operator: In values: [dev, stg, prd] - git: repoURL: https://github.com/acme/gitops-platform.git revision: main directories: - path: components/* - path: components/_experimental exclude: true template: metadata: name: '{{.nameNormalized}}-{{.path.basename}}' namespace: openshift-gitops labels: env: '{{index .metadata.labels "env"}}' region: '{{index .metadata.labels "region"}}' component: '{{.path.basename}}' finalizers: - resources-finalizer.argocd.argoproj.io spec: project: platform source: repoURL: https://github.com/acme/gitops-platform.git targetRevision: main path: '{{.path.path}}/overlays/{{index .metadata.labels "env"}}' destination: server: '{{.server}}' syncPolicy: automated: prune: true selfHeal: true syncOptions: - CreateNamespace=true - ServerSideApply=true - RespectIgnoreDifferences=true retry: limit: 5 backoff: duration: 20s factor: 2 maxDuration: 5m goTemplateOptions: ["missingkey=error"] is not cosmetic: without it, a cluster secret missing the env label silently renders components/cluster-logging/overlays/ and syncs the wrong tree. With it, the generator errors and the ApplicationSet reports the failure.
Dry-run the generation before merging:
argocd appset generate bootstrap/appsets/platform-components.yaml -o yaml | \ yq '.[].metadata.name' Then inspect what the controller actually produced:
argocd appset get platform-components argocd app list -l component=cluster-logging -o wide oc get applications.argoproj.io -n openshift-gitops -l env=prd --no-headers | wc -l Kustomize overlays carry the environment delta
Keep base cluster-agnostic. The overlay encodes retention, replicas, storage class, and nothing else.
# components/cluster-logging/overlays/prd/kustomization.yaml apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: openshift-logging resources: - ../../base patches: - target: group: logging.openshift.io version: v1 kind: ClusterLogging name: instance patch: |- - op: replace path: /spec/logStore/elasticsearch/nodeCount value: 5 - op: replace path: /spec/logStore/retentionPolicy/application/maxAge value: 30d - op: add path: /spec/logStore/elasticsearch/storage/storageClassName value: gp3-csi labels: - pairs: app.kubernetes.io/managed-by: openshift-gitops env: prd includeSelectors: false Validate locally — this is the cheapest CI gate you can add:
for env in dev stg prd; do for c in components/*/; do kustomize build "${c}overlays/${env}" >/dev/null \ || echo "BROKEN: ${c}overlays/${env}" done done Ordering: sync waves inside an Application
Within one Application, argocd.argoproj.io/sync-wave sequences resources. The canonical operator pattern is namespace → OperatorGroup → Subscription → custom resource, with a guard so Argo CD does not dry-run a CR whose CRD does not exist yet.
apiVersion: operators.coreos.com/v1alpha1 kind: Subscription metadata: name: cluster-logging namespace: openshift-logging annotations: argocd.argoproj.io/sync-wave: "0" spec: channel: stable-6.1 name: cluster-logging source: redhat-operators sourceNamespace: openshift-marketplace installPlanApproval: Automatic --- apiVersion: logging.openshift.io/v1 kind: ClusterLogging metadata: name: instance namespace: openshift-logging annotations: argocd.argoproj.io/sync-wave: "10" argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true spec: managementState: Managed Waves are per Application, not global. To order across Applications, put the sync-wave annotation on the generated Application itself (the root app treats them as children) or rely on RollingSync.
Progressive rollout across 30 clusters
Enable progressive syncs on the operator-managed instance, then declare steps matching the labels you set in the ApplicationSet template.
apiVersion: argoproj.io/v1beta1 kind: ArgoCD metadata: name: openshift-gitops namespace: openshift-gitops spec: applicationSet: extraCommandArgs: - --enable-progressive-syncs controller: sharding: enabled: true replicas: 3 processors: operation: 25 status: 50 resources: requests: {cpu: "1", memory: 2Gi} limits: {memory: 6Gi} repo: replicas: 3 # inside spec of the ApplicationSet strategy: type: RollingSync rollingSync: steps: - matchExpressions: - key: env operator: In values: [dev] - matchExpressions: - key: env operator: In values: [stg] maxUpdate: 50% - matchExpressions: - key: env operator: In values: [prd] maxUpdate: 2 Each step waits for the previous batch to reach Healthy. A failing dev cluster stops the promotion before it reaches production. Drive and observe it from the CLI:
argocd app wait -l env=stg --health --timeout 900 --grpc-web argocd app list -l env=prd --output wide | awk '$3!="Synced" || $4!="Healthy"' argocd app diff prd-eu-01-cluster-logging --local components/cluster-logging/overlays/prd Three application-controller shards comfortably handle 30 clusters; the default sharding algorithm distributes clusters by hash. If one cluster is much larger than the others, switch to round-robin via spec.controller.env with ARGOCD_CONTROLLER_SHARDING_ALGORITHM.
Security best practices
Never grant cluster-admin to the Argo CD service account on remote clusters. argocd cluster add --service-account lets you bind a scoped ClusterRole. Build it from the component inventory: operator subscriptions, namespaces, network policies, RBAC — not */*. Audit periodically with oc auth can-i --list --as=system:serviceaccount:openshift-gitops-agent:argocd-manager.
Make AppProject the real boundary. ApplicationSet can generate any Application; the project is what constrains it. Deny cluster-scoped resources you never intend to manage:
apiVersion: argoproj.io/v1alpha1 kind: AppProject metadata: name: platform namespace: openshift-gitops spec: sourceRepos: - https://github.com/acme/gitops-platform.git destinations: - server: '*' namespace: 'openshift-*' - server: '*' namespace: 'platform-*' clusterResourceWhitelist: - group: '' kind: Namespace - group: operators.coreos.com kind: '*' - group: rbac.authorization.k8s.io kind: ClusterRole namespaceResourceBlacklist: - group: '' kind: ResourceQuota - group: security.openshift.io kind: SecurityContextConstraints signatureKeys: - keyID: 4AEE18F83AFDEB23 orphanedResources: warn: true Require signed commits. signatureKeys on the project plus GPG keys loaded into Argo CD means an unsigned commit on main cannot sync. This is the single most effective control against a compromised CI token pushing to the GitOps repo.
No plaintext secrets in Git, ever. Use External Secrets Operator or Sealed Secrets, and add spec.extraConfig resource exclusions so Argo CD does not cache sensitive objects it has no business tracking. Cluster bearer tokens in openshift-gitops are the crown jewels: restrict get secrets in that namespace to the operator's own service accounts.
Lock down the ApplicationSet controller. It can create and delete Applications cluster-wide. Run it with --policy create-update during onboarding so a bad generator cannot mass-delete Applications, and disable SCM providers (--enable-scm-providers=false) if you only use cluster and git generators — SCM generators can be abused to reach arbitrary endpoints with the controller's token.
Use OpenShift OAuth, disable the local admin. Set spec.sso.provider: dex with the OpenShift connector (or your OIDC IdP), map groups to Argo CD RBAC roles, and set spec.disableAdmin: true once SSO works. Give tenants sync on their project only, never applications, delete, */*.
Pin everything. targetRevision: main is convenient for the control plane but dangerous for workloads: pin tenant Applications to tags or commit SHAs, and use installPlanApproval: Manual for operators on production clusters so an operator upgrade is a deliberate Git change.
Conclusion
The pattern that scales to 30 clusters has four moving parts, and none of them is a per-cluster manifest:
- Cluster secrets with labels are the inventory.
env,region,tierdrive every downstream decision. Onboarding a cluster is one labeled secret; offboarding is deleting it, and the ApplicationSet garbage-collects the Applications. - A single root Application bootstraps the ApplicationSets and AppProjects. Exactly one
oc applyin the entire lifecycle of the platform. - Matrix generators (clusters × git directories) turn 40 lines of YAML into hundreds of Applications, with
goTemplateOptions: ["missingkey=error"]to fail fast on incomplete metadata. - Kustomize overlays plus sync waves plus
RollingSynchandle, respectively, the environment delta, intra-application ordering, and inter-cluster promotion.
Add kustomize build validation and argocd appset generate to CI, scope your remote service accounts, enforce signed commits, and the thirty-cluster fleet becomes less operational work than the three-cluster one you started with.
