ArgoCD Kubernetes GitOps

ArgoCD and HPA: constant OutOfSync loop caused by spec.replicas

Fix the infinite OutOfSync loop in ArgoCD caused by the field ownership conflict between HPA and declarative spec.replicas management.

Jerzy Kopaczewski ·
ArgoCD constantly shows OutOfSync for your Deployment or HPA, even though nothing changed in Git. After every sync the status immediately flips back to OutOfSync. This runbook shows how to resolve the conflict between HPA and ArgoCD.

This runbook is part of a series on ArgoCD sync failures. See also: etcd request too large and operation deadlock. For broader GitOps context, read our post on GitOps and ArgoCD.

Symptoms

ArgoCD shows OutOfSync status for a HorizontalPodAutoscaler or Deployment resource. The diff points to a spec.replicas change that doesn’t come from Git:

# ArgoCD shows OutOfSync for HPA resources continuously
# Diff shows spec.replicas changing between desired state and live state
argocd app diff <APP_NAME> --local ./chart

# Output shows:
# HorizontalPodAutoscaler my-app-hpa:
#   spec.replicas: 3 -> 7 (live != desired)

Sync technically succeeds, but HPA immediately changes replicas back, causing an infinite loop: OutOfSync → Sync → OutOfSync. In environments with selfHeal: true, ArgoCD tries to “fix” this drift repeatedly, generating unnecessary operations and loading the controller.

Cause

HPA (HorizontalPodAutoscaler) automatically scales replica count based on metrics (CPU, memory, custom metrics). When HPA changes spec.replicas on a Deployment, that value diverges from the state stored in Git. ArgoCD treats this as drift, a discrepancy between desired state and live state.

This is a classic field ownership conflict between two controllers: ArgoCD wants to maintain the state from Git, while HPA wants to adjust replicas based on load. The same problem affects GitHub ARC (Actions Runner Controller), which dynamically scales runner pods. Identical conflict mechanism.

Fix

Use ignoreDifferences with jqPathExpressions so ArgoCD ignores fields managed by HPA. Additionally enable the RespectIgnoreDifferences sync option so ArgoCD doesn’t overwrite those fields during sync:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/org/app.git
    targetRevision: main
    path: k8s/
  destination:
    server: https://kubernetes.default.svc
    namespace: my-app
  ignoreDifferences:
    - group: apps
      kind: Deployment
      jqPathExpressions:
        - .spec.replicas
    - group: autoscaling
      kind: HorizontalPodAutoscaler
      jqPathExpressions:
        - .spec.metrics[].resource.target.averageUtilization
        - .status
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - RespectIgnoreDifferences=true

Version note: jqPathExpressions requires ArgoCD >= 2.1. The RespectIgnoreDifferences=true option requires ArgoCD >= 2.5. If you’re on an older version, use jsonPointers instead of jqPathExpressions (e.g., /spec/replicas).

# Apply the updated Application manifest
kubectl apply -f application-with-ignore.yaml

# Verify sync status returns to Synced
argocd app get my-app --output json | jq '.status.sync.status'
# Expected: "Synced"

After applying this configuration, ArgoCD won’t treat changes to spec.replicas as drift. HPA can freely scale the Deployment while the Application stays Synced. This approach is preferred over the alternative (removing replicas from Git manifests) because it preserves an explicit default value declaration in the repository.

The same pattern applies to other controllers that dynamically modify resources: Istio injection adding sidecars, cert-manager updating certificate status, or VPA (Vertical Pod Autoscaler) changing requests and limits.

Alternative B) Remove spec.replicas from Git manifest:

Instead of ignoring the difference - remove the field entirely, giving HPA full control:

# Deployment WITHOUT spec.replicas (HPA has sole control)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
spec:
  # replicas: NOT DECLARED - HPA has full control
  # HPA minReplicas defines the minimum, no need for replicas here
  selector:
    matchLabels:
      app: my-app
  template:
    # ...

With Kustomize, ensure the overlay doesn’t add replicas back:

# kustomization.yaml - DO NOT use the replicas: section
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../../base
# ❌ REMOVE:
# replicas:
#   - name: my-app
#     count: 3

Alternative C) Server-Side Diff (ArgoCD 2.10+):

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app
  namespace: argocd
spec:
  source:
    repoURL: https://github.com/org/app.git
    targetRevision: main
    path: k8s/
  destination:
    server: https://kubernetes.default.svc
    namespace: my-app
  ignoreDifferences:
    - group: apps
      kind: Deployment
      managedFieldsManagers:
        - kube-controller-manager  # HPA operates through this manager
      jsonPointers:
        - /spec/replicas
  syncPolicy:
    syncOptions:
      - ServerSideApply=true
      - RespectIgnoreDifferences=true

Alternative D) Global configuration (for all applications):

# argocd-cm ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-cm
  namespace: argocd
data:
  resource.customizations.ignoreDifferences.apps_Deployment: |
    jsonPointers:
      - /spec/replicas
  resource.customizations.ignoreDifferences.apps_StatefulSet: |
    jsonPointers:
      - /spec/replicas
  resource.customizations.ignoreDifferences.argoproj.io_Rollout: |
    jsonPointers:
      - /spec/replicas
kubectl apply -f argocd-cm.yaml
kubectl rollout restart deployment argocd-repo-server -n argocd
kubectl rollout restart deployment argocd-application-controller -n argocd

KEDA ScaledObject - special case:

# With KEDA: ScaledObject creates HPA dynamically
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app
spec:
  ignoreDifferences:
    - group: apps
      kind: Deployment
      jqPathExpressions:
        - .spec.replicas
    - group: autoscaling
      kind: HorizontalPodAutoscaler
      jqPathExpressions:
        - .status
        - .spec.metrics

Validation

# 1. Verify sync status is Synced (not OutOfSync)
argocd app get <APP_NAME> --output json | jq '.status.sync.status'
# Expected: "Synced"

# 2. Wait for HPA to scale (2-3 minutes) and check again
sleep 180
argocd app get <APP_NAME> --output json | jq '.status.sync.status'
# Expected: still "Synced" despite HPA changing replicas

# 3. Verify health status
argocd app get <APP_NAME> --output json | jq '.status.health.status'
# Expected: "Healthy"

# 4. Check that HPA is actually scaling
kubectl get hpa -n <NAMESPACE>
# Expected: TARGETS show current utilization, REPLICAS may differ from Git

If after several HPA scaling cycles the status remains Synced, the problem is resolved. The key is to wait at least one full autoscaling cycle (2-3 minutes) to confirm the OutOfSync loop doesn’t return.

Frequently asked questions about ArgoCD OutOfSync

What does OutOfSync mean in ArgoCD?

OutOfSync is ArgoCD's way of saying the live state in the cluster does not match the desired state declared in Git. ArgoCD continuously diffs what is actually running against your manifests, and any field that differs - a changed image tag, an added annotation, or a replica count moved by something other than Git - flips the application to OutOfSync. It is a status, not an error: it tells you there is drift, not what caused it. Run argocd app diff <APP_NAME> to see exactly which fields differ before deciding whether to sync or to ignore the difference.

Why does my app keep flipping back to OutOfSync right after I sync?

A sync that immediately reverts to OutOfSync means another controller is writing the same field ArgoCD just reconciled. The classic case is an HPA changing spec.replicas: ArgoCD sets it back to the Git value, the HPA moves it again on the next metrics evaluation, and you get an endless OutOfSync -> Sync -> OutOfSync loop. KEDA, VPA, cert-manager, and Istio sidecar injection cause the same field-ownership conflict. The fix is to tell ArgoCD to stop fighting over the contested field with ignoreDifferences plus the RespectIgnoreDifferences=true sync option, rather than disabling self-heal.

How do I stop ArgoCD showing OutOfSync for HPA replica changes?

Add an ignoreDifferences entry that excludes .spec.replicas on the Deployment (via jqPathExpressions) and enable RespectIgnoreDifferences=true in syncOptions so ArgoCD does not overwrite the field during sync. This keeps the default replica value declared in Git while letting the HPA own the live count. Prefer this over deleting spec.replicas from the manifest, which works but drops the explicit default from your repository. On ArgoCD older than 2.1, use jsonPointers (/spec/replicas) instead of jqPathExpressions.

Is an OutOfSync status always a problem?

No. OutOfSync only signals that live state differs from Git, and some drift is expected and harmless - an HPA-managed replica count, a mutating webhook adding fields, or a status subresource updated by a controller. The danger is a permanent OutOfSync loop from a field-ownership conflict: it floods alerts with false positives, so teams learn to ignore the noise and then miss a genuine sync failure. Configure ignoreDifferences for the fields other controllers legitimately own, and keep alerting meaningful by making OutOfSync rare enough to act on.

From an operational cost perspective, an HPA OutOfSync loop generates constant reconciliation attempts that consume ArgoCD controller resources and flood alerting channels with false positives. Teams learn to ignore the noise, which means a genuine sync failure goes unnoticed until a production incident surfaces hours later.

 

Jerzy Kopaczewski

CI/CD pipeline blocking your team?

Book a free 30-minute call. We'll review your ArgoCD configuration and pinpoint what to fix right away.

Book a call