Kubernetes

ArgoCD App-of-Apps: Managing Multi-Environment Clusters

Advanced60 min to complete18 min read

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)
Kubernetes
ArgoCD
GitOps
Multi-Environment
App-of-Apps
Helm

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 dev branch deploys automatically; promoting to prod requires 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:

yaml
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: true

The 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:

bash
kubectl apply -f root-app.yaml

This 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:

yaml
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: true

And apps/dev/redis.yaml following the same pattern with path: charts/redis and namespace: dev.

The important details:

  • targetRevision: main — pinned to the main branch. Never use HEAD; 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 like main is both readable and unambiguous.
  • helm.valueFiles — layers base values with environment-specific overrides. values-dev.yaml is merged on top of values-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:

yaml
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: false

charts/nginx/values-dev.yaml overrides only what differs:

yaml
1replicaCount: 1
2resources:
3  requests:
4    cpu: 50m
5    memory: 64Mi
6  limits:
7    cpu: 200m
8    memory: 128Mi

charts/nginx/values-prod.yaml overrides for production:

yaml
replicaCount: 4
ingress:
  enabled: true
  host: app.yourdomain.com
  tls: true

Helm 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):

yaml
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 prune

With this setup, promotion to production is:

  1. Merge changes to the prod branch (a PR from main or a direct change)
  2. 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:

yaml
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:

bash
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  Auto

The 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:

  1. Test in dev (change is already deployed automatically)
  2. Open a PR that cherry-picks or merges the change from main to the prod branch
  3. After the PR merges, nginx-prod shows as OutOfSync in ArgoCD
  4. Run the sync with an audit trail:
bash
argocd app sync nginx-prod
# TIMESTAMP    GROUP  KIND        NAMESPACE  NAME   STATUS   HEALTH
# 2026-05-21   apps   Deployment  prod       nginx  Synced   Healthy

The 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.

bash
kubeseal --format yaml < secret.yaml > sealed-secret.yaml
# sealed-secret.yaml is safe to commit

External 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

bash
argocd app delete root-dev --cascade
# --cascade deletes the root app AND all child apps it manages
kubectl delete namespace dev prod argocd

GitOps-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:

yaml
metadata:
  name: root-dev
  namespace: argocd
  finalizers:
    - resources-finalizer.argocd.argoproj.io

With 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

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.