GitOpsKubernetesArgoCDAdvancedCI/CDDeployments

Enterprise GitOps Pipelines with ArgoCD & Helm

Jerry RajaonarivoUpdated Sep 30, 20267 min read

This sets up a multi-environment GitOps pipeline with ArgoCD and Helm, using an app-of-apps structure and per-environment value overlays, with automated sync policies that continuously reconcile the cluster against Git and self-heal drift instead of just detecting it.

The app-of-apps pattern

At any scale beyond a handful of services, you don't want one Application per microservice; you want a single root Application that itself manages every other Application. One Git commit to the apps repo is enough to onboard, decommission, or re-point an entire environment.

yaml
# root-app.yaml, the single object a cluster admin ever touches manually
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root-apps
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/acme/gitops-apps.git
    targetRevision: main
    path: apps/production
    directory:
      recurse: true
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Everything under apps/production/ is a plain ArgoCD Application manifest, one per service or one per logical group. The root app watches that directory; add a file, get a new managed app, delete a file, get a clean teardown.

Helm chart structure and per-environment overlays

Under the app-of-apps layer, each service is a Helm chart with a values file per environment rather than per-environment branches or forked repos. Branching your manifests is how you end up with staging and production silently diverging in ways nobody notices until the postmortem.

bash
charts/payments-api/
├── Chart.yaml
├── templates/
│   ├── deployment.yaml
│   ├── service.yaml
│   └── hpa.yaml
├── values.yaml            # shared defaults
├── values-staging.yaml    # overlay
└── values-production.yaml # overlay
yaml
# values-production.yaml
replicaCount: 6
 
resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    memory: 1Gi
 
autoscaling:
  enabled: true
  minReplicas: 6
  maxReplicas: 24
 
env:
  LOG_LEVEL: warn
  FEATURE_FLAGS_SOURCE: launchdarkly-prod

values.yaml never sets replica counts or resource requests and limits directly. Every environment-sensitive field lives in the overlay, so the diff between staging and production is a diff between two YAML files on the same chart version.

Wiring the Application CRD to Helm with automated sync

Each child Application points at the chart and the environment overlay, and turns on automated sync with prune and selfHeal.

yaml
# apps/production/payments-api.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: payments-api
  namespace: argocd
spec:
  project: production
  source:
    repoURL: https://github.com/acme/gitops-apps.git
    targetRevision: main
    path: charts/payments-api
    helm:
      valueFiles:
        - values.yaml
        - values-production.yaml
  destination:
    server: https://kubernetes.default.svc
    namespace: payments
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
    retry:
      limit: 5
      backoff:
        duration: 10s
        factor: 2
        maxDuration: 3m

prune: true means resources removed from the chart get removed from the cluster on the next sync. Without it, deleted templates leave orphaned objects behind indefinitely. selfHeal: true reverts any manual change to a live object back to what's in Git, automatically, on the next reconciliation pass (by default every three minutes, or immediately if you're watching the resource).

selfHeal has a downside. If someone hot-fixes a live Deployment with kubectl during an incident, ArgoCD reverts it on the next reconciliation and the fix disappears without anyone noticing. You handle this with process. Your incident runbook needs an explicit step, before anyone touches a live resource by hand, to either commit the patch immediately or pause auto-sync first on both the child app and the root app that owns it. The workflow is further down.

The child points at project: production, and that AppProject has to exist before the Application can sync. It limits which repos this app may pull from and where it may deploy. I keep it in the same directory, so the root app manages it too, with a lower sync wave so it's created first:

yaml
# apps/production/project.yaml
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: production
  namespace: argocd
  annotations:
    argocd.argoproj.io/sync-wave: "-1"
spec:
  description: Production workloads
  sourceRepos:
    - https://github.com/acme/gitops-apps.git
  destinations:
    - server: https://kubernetes.default.svc
      namespace: payments
  # cluster-scoped kinds the apps may create: Namespace for
  # CreateNamespace=true, CRDs for charts that ship them
  clusterResourceWhitelist:
    - group: ""
      kind: Namespace
    - group: apiextensions.k8s.io
      kind: CustomResourceDefinition

Every new production namespace has to be added under destinations in a reviewed commit. That way a typo in a child Application's destination fails the sync instead of deploying somewhere unexpected.

Sync waves and hooks for ordering

Helm's template rendering has no concept of "apply this before that" across separate charts. Plenty of real deployments need it anyway: CRDs before the controllers that watch them, or a migration Job that has to finish before the Deployment depending on the new schema comes up. ArgoCD's sync waves solve this with a plain annotation.

yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: postgresqls.acme.io
  annotations:
    argocd.argoproj.io/sync-wave: "-1"
---
apiVersion: batch/v1
kind: Job
metadata:
  name: payments-db-migrate
  annotations:
    argocd.argoproj.io/sync-wave: "0"
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/hook-delete-policy: HookSucceeded
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payments-api
  annotations:
    argocd.argoproj.io/sync-wave: "1"

Lower waves sync first, and ArgoCD waits for each wave's resources to be Healthy before moving to the next. Combined with PreSync hooks for one-shot jobs like migrations, this replaces the fragile "run this script before that Helm release" step.

Drift detection and the manual override workflow

Even with selfHeal on, you need a documented path for legitimate manual intervention. When an incident requires a live patch faster than a PR-and-merge cycle allows, you have to pause auto-sync, and pausing only the child Application doesn't work. The child's syncPolicy is defined in Git and owned by root-apps, which also has selfHeal: true. The root sees your edit as drift and puts automated back within minutes, often while you're still patching. Pause the root first, then the child:

bash
# 1. stop the root app from reverting child Applications
argocd app set root-apps --sync-policy none
# 2. pause the affected app, then patch the live resource
argocd app set payments-api --sync-policy none
# 3. once the fix is merged in Git, check the app matches it
argocd app diff payments-api
argocd app sync payments-api
# 4. re-enable the root: it restores payments-api's syncPolicy from Git
argocd app set root-apps --sync-policy automated --auto-prune --self-heal

Then either revert the manual change or commit it to Git within the same incident window, and re-enable the root only after the app is confirmed in sync. While the root is paused, no Application gets onboarded or removed, so keep that window short. If you'd rather never touch the root during incidents, you can add an ignoreDifferences entry on the root app for /spec/syncPolicy of child Applications. The downside is that the root then also ignores syncPolicy changes you make in Git on purpose.

Outside of an active incident, drift shows up in the ArgoCD UI and API as OutOfSync. It's worth alerting on directly: more than a few minutes of it on a production app, with no incident in progress, means someone bypassed the process.

To roll back a bad release, I start with git revert on the offending commit. Auto-sync ships it like any other change, and Git still matches what's running. argocd app rollback refuses to run against an Application with automated sync enabled, so you'd have to disable auto-sync first (on the root app too, as above), and the app stays out of step with Git until you revert there anyway. It can also only go back as far as the sync history the Application keeps, which is revisionHistoryLimit, 10 entries by default.

Someone will still patch a live resource by hand during a bad incident. What changes is what happens next: either the patch lands in Git and reconciliation confirms it, or selfHeal quietly reverts it and you find out in minutes instead of at the next unrelated deploy.

GO FURTHER

Want to actually run this in production?

This tutorial covers the concepts and architecture. If you want to implement it in your own infrastructure, or get good enough to own this problem long-term, I offer 1:1 mentoring built around your real environment, not a generic course.

This tutorial

  • Core architecture & key concepts
  • Illustrative code snippets
  • The reasoning behind each decision

1:1 mentoring

  • Working sessions on your own environment
  • Direct answers to the edge cases you're hitting
  • Feedback on your actual implementation
  • Ongoing support as you build it out
Get in TouchReply within 1 business day.

Related Tutorials