KH.

RESOURCE · ARGOCD PATTERN

A real ArgoCD app-of-apps pattern (with notes on what NOT to do)

Bootstrap Application, environment-scoped ApplicationSets, AppProjects with sync windows — and the anti-patterns I see in real engagements.

ArgoCD 2.11+ApplicationSetAppProjectHelm 3

What you get

  • A bootstrap Application (the only manual kubectl apply you ever do), plus environment Applications for staging and production
  • ApplicationSets with git generator — adding a new service is as simple as creating a directory under services/production/
  • AppProjects that lock down what each environment can deploy to, with production sync windows that restrict deploys to business hours

What it assumes

  • ArgoCD 2.11+ installed in the target cluster (ApplicationSet is bundled since 2.6)
  • A dedicated GitOps repository (separate from your application code) for cluster state
  • Your services are packaged as Helm charts with environment-specific values files

The code

# The bootstrap Application is the only thing you apply manually.
# Everything else is managed by ArgoCD from this point on.
#
#   kubectl apply -f bootstrap/app.yaml
#
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: bootstrap
  namespace: argocd
  # Cascade deletion — removing the bootstrap app removes everything.
  # Set this to 'foreground' in production; leave unset in dev.
  finalizers:
    - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    repoURL: https://github.com/your-org/gitops-config.git
    targetRevision: HEAD
    path: envs          # Points at the envs/ directory, which contains the env apps
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true       # Remove resources that no longer exist in Git
      selfHeal: true    # Revert manual kubectl edits
    syncOptions:
      - CreateNamespace=true
      - PrunePropagationPolicy=foreground
      - PruneLast=true

The explanation

The one thing you apply manually

The entire pattern is bootstrapped by one command:

kubectl apply -f bootstrap/app.yaml

After that, ArgoCD manages everything — including itself, if you include ArgoCD's own Helm chart in the bootstrap. The bootstrap Application points at theenvs/ directory, which contains per-environment Applications. Each environment Application points at the environment's directory, which contains ApplicationSets. The ApplicationSets discover services by scanning subdirectories. Adding a new service is a directory commit. Removing a service is a directory deletion (with pruning enabled, ArgoCD removes the Kubernetes resources automatically).

ApplicationSet vs individual Applications

A common mistake is creating one ArgoCD Application per service per environment manually. For 10 services across 3 environments, that's 30 Application objects to maintain. When the GitOps repo URL changes, or you add a new environment, you update all 30. With an ApplicationSet and the git generator, you update one template. The generator scans the configured directory path and creates Applications automatically for each subdirectory it finds.

The directory structure this pattern expects:

services/
  production/
    api/                 # Creates api-production Application
      values.yaml
      values-production.yaml
    worker/              # Creates worker-production Application
      values.yaml
      values-production.yaml
  staging/
    api/                 # Creates api-staging Application
    worker/

AppProjects are not optional for production

The default ArgoCD project allows applications to deploy to any namespace on any cluster and pull from any repository. This is fine in a single-environment cluster. In a shared cluster or a production environment, it's a misconfiguration waiting to become an incident. The AppProject in this pattern:

  • Restricts which repositories applications can use as sources (prevents a rogue PR from pointing an Application at a malicious Helm repo)
  • Defines a whitelist of allowed cluster resources (prevents applications from creating ClusterRoles they shouldn't have)
  • Configures sync windows (production deploys only happen during business hours unless a developer manually approves an out-of-window sync)

Common mistakes I see in real engagements

Storing the GitOps config in the application repo

When ArgoCD configuration lives in the same repo as application code, every code change is a potential GitOps config change. More importantly, the permissions required to merge application code and deploy to production are the same. Use a separate GitOps repository with its own access controls — code review merges to the app repo, deployment review merges to the GitOps repo.

Disabling pruning

Many teams disable pruning because they're afraid of ArgoCD deleting things. Without pruning, deleted resources in Git persist indefinitely in the cluster. You end up with ghost Deployments from services that were renamed, old ConfigMaps from configuration that was moved to Secrets Manager, and NetworkPolicies from a security model that changed six months ago. Enable pruning, but enable PruneLast=true so resources are deleted after new ones are healthy, not before.

Using the argocd namespace for everything

Placing all Applications in the argocd namespace is the default and it works, but it means all ArgoCD users — including read-only developers — can see all Applications. Use AppProject roles and RBAC to limit what each team can see and do. A developer on the payments team doesn't need to see the infrastructure Applications.

No health checks on Application resources

ArgoCD has built-in health checks for standard Kubernetes resources (Deployments, StatefulSets, etc.), but custom resources (CRDs from Prometheus Operator, Cert-Manager, etc.) are reported as Healthy by default because ArgoCD doesn't know their health semantics. Write custom health check Lua scripts for your CRDs, or ArgoCD will report your monitoring stack as healthy even when a PrometheusRule has a syntax error.

Customisation notes

The most common customisation is adding a third environment (dev). Create anenvs/dev/ directory, copy the staging ApplicationSet, change the path prefix to services/dev/ and the namespace suffix to -dev. If dev lives in a separate cluster, change the destination.server to the dev cluster's API endpoint (you'll need to add it to ArgoCD with argocd cluster add).

The production sync window currently allows deploys Mon–Fri 06:00–16:00 UTC. Adjust the cron expression to match your team's actual deployment policy. If you use a change management system (ServiceNow, Jira), configure ArgoCD notifications to create a change request automatically when a sync is requested outside the window, rather than silently blocking it.

For multi-cluster setups, add a second generator to the ApplicationSet'sgenerators list using the cluster generator — it creates Applications for each registered cluster matching a label selector. This is how you scale the same pattern to 10 clusters without 10× the YAML.

Need this customised for your stack?

The resource above covers the general case. If you need it adapted to your specific cloud account, VPC layout, security requirements, or team structure, that's what engagements are for.

Let's talk