Back to Distributed Systems & Kubernetes Series

Argo CD Track Part 2: Applications & Sync

June 6, 2026 Wasil Zafar 26 min read

A single Application is just the beginning. Real GitOps environments manage multiple Applications per service, separate chart repos from config repos, control when syncs happen with sync windows, and use resource hooks for fine-grained sync ordering. This part covers all of it hands-on.

Table of Contents

  1. Multi-Source Applications
  2. Sync Windows
  3. Resource Hooks
  4. Health Checks & Status
  5. Sync Options Deep-Dive
  6. UI Walkthrough
  7. Exercises
  8. Key Takeaways & Next Steps

Multi-Source Applications

Why Separate Chart from Config?

The most mature GitOps pattern separates the Helm chart (owned by the application team) from the environment-specific values (owned by the platform or ops team). With a single-source Application you must store both in the same repo, which creates friction:

  • Application developers can't change chart versions without access to the config repo
  • Ops teams can't update environment values without touching the chart repo
  • Chart publishing to an OCI registry becomes impossible if the chart and values must coexist

Multi-source Applications (Argo CD v2.6+) solve this elegantly.

Multi-Source Application Spec

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: grade-api-prod
  namespace: argocd
spec:
  # 'sources' replaces single 'source' for multi-source
  sources:
    # Source 1: Helm chart from OCI registry
    - repoURL: oci://ghcr.io/your-org/charts
      chart: grade-api
      targetRevision: 0.2.0
      helm:
        releaseName: grade-api

    # Source 2: Values files from separate config repo
    # $values is a special ref to the second source's root
    - repoURL: https://github.com/your-org/grade-api-gitops
      targetRevision: HEAD
      ref: values   # name this source 'values' for referencing

  # Back in Source 1, reference the config repo
  # (This pattern requires Argo CD v2.6+)
  # Use helm.valueFiles: $values/environments/production/values.yaml
  # in the first source

  destination:
    server: https://kubernetes.default.svc
    namespace: production
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
Practical Multi-Source Setup: The ref key names a source so it can be referenced by other sources. In Source 1's helm.valueFiles, use $values/path/to/values.yaml where $values matches the ref name you gave the config repo source. Argo CD fetches both repos and combines them during rendering.
# Complete multi-source example with ref
spec:
  sources:
    - repoURL: oci://ghcr.io/your-org/charts
      chart: grade-api
      targetRevision: 0.2.0
      helm:
        releaseName: grade-api
        valueFiles:
          - $values/environments/production/values.yaml
          - $values/secrets/production-overrides.yaml

    - repoURL: https://github.com/your-org/grade-api-gitops
      targetRevision: main
      ref: values   # ← this 'values' matches $values above

Sync Windows

Sync windows control when Argo CD is allowed to sync Applications. Use them to prevent deployments during business hours, mandate change freeze periods, or restrict production deployments to specific time slots:

Allow vs Deny Windows

  • Allow window: Syncs are only permitted during the window. Outside the window, auto-sync is blocked.
  • Deny window: Syncs are blocked during the window. Useful for "no deployments on Friday afternoon" policies.

Window Specification

Sync windows are configured in AppProject (covered in Part 4), not directly in Applications. But you can test them via the CLI:

# AppProject with sync windows (preview — full Projects in Part 4)
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: production
  namespace: argocd
spec:
  syncWindows:
    # Allow deployments Mon-Fri 9am-5pm UTC
    - kind: allow
      schedule: "0 9 * * 1-5"    # cron: at 09:00, Mon-Fri
      duration: 8h
      applications:
        - "*"
      namespaces:
        - production
      manualSync: true    # allow manual syncs outside window

    # Deny deployments on Fridays 3pm-6pm (code freeze)
    - kind: deny
      schedule: "0 15 * * 5"     # at 15:00 on Friday
      duration: 3h
      applications:
        - "*"
# Check if sync is currently allowed for an app
argocd app get grade-api-prod | grep "Sync Window"
# SyncWindow:   Sync Allowed   (or "Sync Blocked")

# Force sync even when window blocks (requires --force)
argocd app sync grade-api-prod --force

Resource Hooks

Argo CD Hooks vs Helm Hooks

Both Argo CD and Helm have hooks, but they serve different purposes and are not interchangeable:

Argo CD Hooks: Use argocd.argoproj.io/hook annotation. Apply during Argo CD sync phases (PreSync, Sync, PostSync, SyncFail). Work with any source type (plain YAML, Kustomize, Helm).

Helm Hooks: Use helm.sh/hook annotation. Apply during helm install/helm upgrade lifecycle. Only relevant when Argo CD uses a Helm source. Important: When Argo CD manages a Helm chart, Helm hooks are executed by Argo CD, not by Helm directly — so helm.sh/hook-delete-policy is ignored by Argo CD (you must use argocd.argoproj.io/hook-delete-policy instead).

PreSync Job

# Run a database migration before syncing any other resources
apiVersion: batch/v1
kind: Job
metadata:
  name: grade-api-migrate-{{ now | unixEpoch }}
  namespace: production
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
  backoffLimit: 3
  template:
    spec:
      restartPolicy: Never
      containers:
      - name: migrate
        image: ghcr.io/example/grade-api:{{ .Values.image.tag }}
        command: ["./migrate", "--direction=up", "--all"]
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: grade-api-db-secret
              key: database-url

PostSync Job

# Send a Slack notification after successful sync
apiVersion: batch/v1
kind: Job
metadata:
  name: grade-api-deploy-notify
  namespace: production
  annotations:
    argocd.argoproj.io/hook: PostSync
    argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
      - name: notify
        image: curlimages/curl:8.6.0
        command:
        - sh
        - -c
        - |
          curl -X POST $SLACK_WEBHOOK_URL \
            -H 'Content-Type: application/json' \
            -d '{"text": "grade-api deployed to production successfully"}'
        env:
        - name: SLACK_WEBHOOK_URL
          valueFrom:
            secretKeyRef:
              name: slack-webhook
              key: url

# Hook phases available:
# PreSync    - before any resources are applied
# Sync       - during the sync (same time as other resources)
# PostSync   - after all resources are Healthy
# SyncFail   - only if sync fails (for alerting)
# Skip       - never synced (useful for manually-managed resources)

Health Checks & Status

Built-in Health Assessors

Argo CD includes built-in health assessors for standard Kubernetes resource types:

  • Deployment: Healthy when all desired replicas are available
  • StatefulSet: Healthy when all replicas are updated and available
  • DaemonSet: Healthy when all nodes have the desired pods ready
  • Job: Healthy when complete, degraded when failed
  • PVC: Healthy when Bound, degraded when Pending for too long
  • Service (LoadBalancer): Healthy when external IP is assigned
  • Custom CRDs: No built-in assessors — need Lua health checks

Custom Health Checks (Lua)

For CRDs (Crossplane resources, cert-manager Certificates, etc.) you write Lua scripts that Argo CD evaluates:

# argocd-cm ConfigMap — add custom health checks
apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-cm
  namespace: argocd
data:
  # Health check for cert-manager Certificate
  resource.customizations.health.cert-manager.io_Certificate: |
    hs = {}
    if obj.status ~= nil then
      if obj.status.conditions ~= nil then
        for i, condition in ipairs(obj.status.conditions) do
          if condition.type == "Ready" and condition.status == "False" then
            hs.status = "Degraded"
            hs.message = condition.message
            return hs
          end
          if condition.type == "Ready" and condition.status == "True" then
            hs.status = "Healthy"
            hs.message = "Certificate is ready"
            return hs
          end
        end
      end
    end
    hs.status = "Progressing"
    hs.message = "Waiting for certificate"
    return hs

  # Health check for Crossplane Managed Resource
  resource.customizations.health.database.crossplane.io_PostgreSQLInstance: |
    hs = {}
    if obj.status.conditions ~= nil then
      for i, condition in ipairs(obj.status.conditions) do
        if condition.type == "Ready" then
          if condition.status == "True" then
            hs.status = "Healthy"
            hs.message = "Database is provisioned and ready"
            return hs
          else
            hs.status = "Progressing"
            hs.message = condition.message
            return hs
          end
        end
      end
    end
    hs.status = "Progressing"
    hs.message = "Waiting for database to be ready"
    return hs

Sync Options Deep-Dive

Sync options control low-level sync behavior. The most important ones for production:

syncPolicy:
  syncOptions:
    # Create the namespace if it doesn't exist (saves a kubectl create ns step)
    - CreateNamespace=true

    # Use Server-Side Apply instead of client-side apply
    # Required for large CRDs that exceed client-side apply's 256KB limit
    # Also prevents "last-applied-configuration annotation too long" errors
    - ServerSideApply=true

    # Validate manifests with the API server's dry-run before applying
    # Catches schema validation errors early (default: true)
    - Validate=true

    # Control how pruned resources are deleted
    # foreground: parent deleted first, then children
    # background: children deleted in background (default)
    # orphan: remove argocd tracking labels but don't delete
    - PrunePropagationPolicy=foreground

    # Only prune last sync was successful (prevents cascade deletes during failures)
    - PruneLast=true

    # Replace instead of patch for resources that can't be patched
    # (useful for immutable fields)
    - Replace=false

    # Apply Out Of Sync resources only (skip Synced resources)
    # Faster but may miss resources that need refresh
    - ApplyOutOfSyncOnly=true

UI Walkthrough

The Argo CD UI is the primary operational interface. Key areas to understand:

Application List View: Shows all Applications with sync status (Synced/OutOfSync/Unknown) and health status (Healthy/Progressing/Degraded/Missing). Click any Application to enter the tree view.
Application Tree View: Visual graph of all Kubernetes resources managed by the Application — Deployments, ReplicaSets, Pods, Services, ConfigMaps. Each node shows its health status. Green = Healthy, Yellow = Progressing, Red = Degraded. Click any node to see its YAML, events, and logs.
Diff View: Click "APP DIFF" to see what would change if you synced now. Shows the delta between Git desired state and live cluster state — equivalent to argocd app diff but visual.
# Useful CLI commands that mirror UI features

# Show the diff (what's out of sync)
argocd app diff grade-api-prod

# Get detailed resource tree
argocd app resources grade-api-prod

# Show resource details (events, status)
argocd app resource grade-api-prod \
  --kind Deployment \
  --resource-name grade-api

# Get logs for a pod managed by the Application
argocd app logs grade-api-prod \
  -c grade-api \
  --tail 100

# Force hard refresh (re-fetch from Git, don't use cache)
argocd app get grade-api-prod --hard-refresh

Exercises

Exercise 1 — Multi-Source: Split your grade-api GitOps repo into two repos: one with the Helm chart, one with environment values. Create a multi-source Application that references the chart from the first repo and values from the second. Verify argocd app get shows both sources in the output.
Exercise 2 — PreSync Hook: Add a PreSync Job to the grade-api Application that runs echo "Pre-sync health check passed". Trigger a sync and watch the Job run before the Deployment is updated. Check argocd app get --watch to see the PreSync phase.
Exercise 3 — SyncFail Hook: Add a SyncFail Job that logs "DEPLOYMENT FAILED" to stdout. Intentionally break the Application (set image.tag to a non-existent tag). Verify the SyncFail hook runs and the Job logs appear in the Argo CD UI.

Key Takeaways & Next Steps

Key Takeaways:
  • Multi-source Applications separate chart ownership from config ownership — the right model for large teams
  • Sync windows enforce change freeze periods and deployment schedules declaratively
  • Argo CD hooks (argocd.argoproj.io/hook) work across all source types; Helm hooks are Helm-specific
  • Built-in health assessors cover standard K8s types; custom Lua scripts handle CRDs
  • ServerSideApply=true is essential for CRDs and large resources
  • The UI's tree view and diff view are the primary operational tools for GitOps visibility

Next in This Track

In Part 3: App-of-Apps & Sync Waves, we solve the multi-Application management problem — using the App-of-Apps pattern and ApplicationSets to manage hundreds of Applications, plus sync waves for dependency ordering between Applications.