ArgoCD App-of-Apps: Managing Multi-Environment Clusters
Use ArgoCD's app-of-apps pattern to manage dozens of applications across dev, staging, and production from a single root Application. Covers repo structure, sync waves, environment value overrides, and preventing accidental prod deploys.
Before you begin
- ArgoCD installed on a cluster (see tutorial: Getting Started with ArgoCD)
- Git repository you control
- Basic Helm knowledge (values files
- chart structure)
Once you have five or more applications running across three environments, the imperative approach falls apart. You're running argocd app create commands in CI scripts, tracking which apps exist where in a spreadsheet, and manually syncing prod after each merge. Someone eventually deletes an app instead of syncing it.
The app-of-apps pattern fixes this by putting Application manifests in Git. ArgoCD manages the Applications the same way it manages your workloads — declaratively, with the cluster always converging toward what's in the repository.
What You'll Build
A GitOps repository where:
- A single root Application (created once, manually) points to a directory of Application manifests
- That directory contains child Application YAMLs for each service in each environment
- Merging to the
devbranch deploys automatically; promoting toprodrequires an explicit sync
gitops-repo/
├── apps/
│ ├── dev/
│ │ ├── nginx.yaml # Application for nginx in dev
│ │ └── redis.yaml # Application for redis in dev
│ └── prod/
│ ├── nginx.yaml # Application for nginx in prod (manual sync)
│ └── redis.yaml
├── charts/
│ ├── nginx/ # Helm chart for nginx
│ │ ├── Chart.yaml
│ │ ├── templates/
│ │ ├── values-base.yaml
│ │ ├── values-dev.yaml
│ │ └── values-prod.yaml
│ └── redis/
│ └── ...
└── root-app.yaml # The one Application you create by hand
Step 1: Understand the Pattern
In the standard GitOps setup, you have one Application per service. In app-of-apps, you have one root Application whose path points to a directory of Application YAMLs. ArgoCD finds those YAMLs and creates (or syncs) the child Applications. The root Application manages the Applications; the child Applications manage the workloads.
The key insight: Application resources live in the argocd namespace, just like any other Kubernetes resource. ArgoCD can manage them declaratively, the same way it manages Deployments and Services.
Step 2: Create the Root Application
This is the only Application you create manually (once). All others are managed by ArgoCD after this:
1# root-app.yaml
2apiVersion: argoproj.io/v1alpha1
3kind: Application
4metadata:
5 name: root-dev
6 namespace: argocd
7 annotations:
8 argocd.argoproj.io/sync-wave: "-1"
9spec:
10 project: default
11 source:
12 repoURL: https://github.com/your-org/gitops-repo
13 targetRevision: main
14 path: apps/dev
15 destination:
16 server: https://kubernetes.default.svc
17 namespace: argocd
18 syncPolicy:
19 automated:
20 prune: true
21 selfHeal: trueThe destination.namespace: argocd is correct — the root app is creating Application resources which live in the argocd namespace. The child Applications will point to their own destination namespaces.
Apply it:
kubectl apply -f root-app.yamlThis is the last kubectl apply you'll do. Everything from here is managed through Git.
Step 3: Child Application Templates
Create apps/dev/nginx.yaml in your repo:
1apiVersion: argoproj.io/v1alpha1
2kind: Application
3metadata:
4 name: nginx-dev
5 namespace: argocd
6 annotations:
7 argocd.argoproj.io/sync-wave: "0"
8spec:
9 project: default
10 source:
11 repoURL: https://github.com/your-org/gitops-repo
12 targetRevision: main
13 path: charts/nginx
14 helm:
15 valueFiles:
16 - values-base.yaml
17 - values-dev.yaml
18 destination:
19 server: https://kubernetes.default.svc
20 namespace: dev
21 syncPolicy:
22 automated:
23 prune: true
24 selfHeal: trueAnd apps/dev/redis.yaml following the same pattern with path: charts/redis and namespace: dev.
The important details:
targetRevision: main— pinned to themainbranch. Never useHEAD; ArgoCD resolves it to the latest commit SHA at sync time, which means two back-to-back syncs during an active push could pick up different revisions. A branch name likemainis both readable and unambiguous.helm.valueFiles— layers base values with environment-specific overrides.values-dev.yamlis merged on top ofvalues-base.yaml.destination.namespace: dev— each child app deploys to its own namespace.
Step 4: Environment-Specific Value Overrides
charts/nginx/values-base.yaml defines the defaults:
1replicaCount: 2
2image:
3 repository: nginx
4 tag: "1.25.0"
5resources:
6 requests:
7 cpu: 100m
8 memory: 128Mi
9 limits:
10 cpu: 500m
11 memory: 256Mi
12ingress:
13 enabled: falsecharts/nginx/values-dev.yaml overrides only what differs:
1replicaCount: 1
2resources:
3 requests:
4 cpu: 50m
5 memory: 64Mi
6 limits:
7 cpu: 200m
8 memory: 128Micharts/nginx/values-prod.yaml overrides for production:
replicaCount: 4
ingress:
enabled: true
host: app.yourdomain.com
tls: trueHelm merges these files in order — later files take precedence. You change production settings by modifying values-prod.yaml and merging the PR.
Step 5: Production Applications — Manual Sync
The prod child applications should require explicit operator approval before syncing. Set syncPolicy: {} (empty — no automated sync):
1# apps/prod/nginx.yaml
2apiVersion: argoproj.io/v1alpha1
3kind: Application
4metadata:
5 name: nginx-prod
6 namespace: argocd
7 annotations:
8 argocd.argoproj.io/sync-wave: "0"
9spec:
10 project: default
11 source:
12 repoURL: https://github.com/your-org/gitops-repo
13 targetRevision: prod # separate branch for prod
14 path: charts/nginx
15 helm:
16 valueFiles:
17 - values-base.yaml
18 - values-prod.yaml
19 destination:
20 server: https://kubernetes.default.svc
21 namespace: prod
22 syncPolicy: {} # manual sync only — no automated, no pruneWith this setup, promotion to production is:
- Merge changes to the
prodbranch (a PR frommainor a direct change) - Run
argocd app sync nginx-prod(or click sync in the UI)
No silent automated deploys to production.
Step 6: Sync Waves
Sync waves control the order in which resources are applied during a sync. Resources with a lower wave number are applied and become healthy before resources with higher numbers start.
The annotation argocd.argoproj.io/sync-wave: "-1" on the root app ensures it's processed before child apps (wave 0). This matters when the root app has dependencies — for example, if you need a namespace to exist before the child Application deploys into it, put the namespace in wave -2:
1# A Namespace manifest that ArgoCD creates before any child apps
2apiVersion: v1
3kind: Namespace
4metadata:
5 name: dev
6 annotations:
7 argocd.argoproj.io/sync-wave: "-2"Useful wave ordering for a typical setup:
- Wave -2: Namespaces, CRDs
- Wave -1: Root Applications, secrets operators
- Wave 0: Application workloads (default)
- Wave 1: Integration tests, smoke checks (if using ArgoCD hooks)
Step 7: Verify Child Apps Are Created
After committing apps/dev/nginx.yaml and apps/dev/redis.yaml to your repo and pushing:
argocd app list
# NAME CLUSTER NAMESPACE PROJECT STATUS HEALTH SYNCPOLICY
# root-dev in-cluster argocd default Synced Healthy Auto
# nginx-dev in-cluster dev default Synced Healthy Auto
# redis-dev in-cluster dev default Synced Healthy AutoThe child apps were created by ArgoCD, not by you. If you delete apps/dev/redis.yaml from Git and merge, the root app's prune: true setting will delete the redis-dev Application — which then cascades to deleting the Redis workload from the cluster.
Step 8: Promotion Workflow
To promote a change from dev to prod:
- Test in dev (change is already deployed automatically)
- Open a PR that cherry-picks or merges the change from
mainto theprodbranch - After the PR merges,
nginx-prodshows as OutOfSync in ArgoCD - Run the sync with an audit trail:
argocd app sync nginx-prod
# TIMESTAMP GROUP KIND NAMESPACE NAME STATUS HEALTH
# 2026-05-21 apps Deployment prod nginx Synced HealthyThe sync operation itself is tracked in ArgoCD history, giving you a record of who triggered it and when.
Step 9: Managing Secrets
The one thing you cannot put in Git unencrypted is secrets. Three practical options:
Sealed Secrets (simplest to operate): Encrypt secrets client-side with a cluster public key. Store the encrypted SealedSecret manifest in Git. The controller decrypts it in the cluster.
kubeseal --format yaml < secret.yaml > sealed-secret.yaml
# sealed-secret.yaml is safe to commitExternal Secrets Operator (best for teams already using Vault/AWS SSM): A CRD that fetches secrets from external stores at runtime. The ExternalSecret manifest describes which secret to fetch and where from — no secret values in Git at all.
SOPS (good for Helm): Encrypts individual values in your Helm values files using AWS KMS, GCP KMS, or age keys. ArgoCD has a SOPS plugin that decrypts at sync time.
For most teams I'd recommend External Secrets if you already have a secrets manager. Sealed Secrets if you don't want another dependency. SOPS if your team is comfortable with it and you're already using Helm.
Common Mistakes to Avoid
Creating child Applications manually — the whole point is that Applications live in Git. If you argocd app create nginx-dev directly, it won't be tracked by the root app, won't be pruned when you want to remove it, and breaks the GitOps invariant.
selfHeal: true on prod — self-heal reverts any manual change to match Git. On production, you sometimes need to apply a hotfix via kubectl and then create the PR after. With self-heal on, ArgoCD reverts your hotfix before your PR can merge. Disable it on prod.
Not pinning targetRevision — using HEAD resolves to the latest commit SHA at sync time. If a commit lands mid-sync, consecutive sync operations on the same run could pick up different revisions. Pin to a branch name (main, prod) so the revision is explicit and auditable.
Enabling prune: true before testing — when first setting up, prune will delete resources that aren't in your Git repo yet. This includes existing workloads you haven't migrated to GitOps. Enable prune only after you've committed all existing resources to the repo.
Not using namespaces per environment — deploying nginx-dev and nginx-prod to the same namespace with different names is fragile. Use separate namespaces (dev, staging, prod) so that RBAC, NetworkPolicies, and resource quotas can be applied per-environment cleanly.
Cleanup
argocd app delete root-dev --cascade
# --cascade deletes the root app AND all child apps it manages
kubectl delete namespace dev prod argocdGitOps-preferred alternative: Rather than relying on the --cascade CLI flag, add the resources-finalizer.argocd.argoproj.io finalizer to the root Application manifest. This ensures cascading deletion happens declaratively through the Application CRD, not imperatively through CLI:
metadata:
name: root-dev
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.ioWith the finalizer in place, kubectl delete application root-dev triggers the same cascading cleanup without needing the --cascade flag.
What's Next
- ApplicationSet Controller — generate Applications programmatically from a list of clusters or Git directories, the next step beyond app-of-apps
Official References
- ArgoCD App of Apps Pattern — official documentation on the pattern with cluster bootstrapping examples
- Sync Waves and Phases — controlling sync order with wave annotations and resource hooks
- Helm Integration — full reference for using Helm charts with ArgoCD, including value file layering and parameter overrides
- External Secrets Operator — syncing secrets from AWS SSM, Vault, GCP Secret Manager into Kubernetes
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.