Field Notes Blog cover
GitOps

ArgoCD Multi-Source Helm: Decoupling Chart Versions from Configuration

One source for the chart in your registry. One source for the values in Git. They version independently — and that changes everything about how you manage environments.

A
Ashutosh Upadhyay
Platform Engineering · August 2026

Updated 4 October 2026 with technical corrections.

Managing Helm deployments with ArgoCD starts simple and quietly becomes a versioning problem. The chart and the configuration live in the same place. Every environment update touches the same files as chart code changes. CI pipelines and ops workflows contend for the same repository. At some point you realize you've coupled two things that had no reason to be coupled.

ArgoCD's multi-source feature solves this. The chart lives in a Helm registry — OCI or HTTP. The values live in a GitOps configuration repository. They evolve on independent timelines, versioned separately, bumped by different systems. This post covers the three patterns this enables and the operational conventions that make them work in practice.

The Problem with Single-Source Helm GitOps

The conventional ArgoCD pattern stores the Helm chart and its values in the same repository — or at minimum the same ArgoCD Application manifest. This works until you have more than one environment. Then you end up with one of two uncomfortable choices:

In both cases, the chart version and the environment configuration are entangled. A chart update requires touching the configuration repo. A configuration change carries implicit risk of also picking up a chart version change. The coupling isn't necessary — it's a consequence of the tooling forcing everything into one source.

Multi-source removes the constraint. The chart comes from wherever charts live — your registry, a public Helm repo, OCI. The values come from your GitOps config repo. ArgoCD renders them together at sync time. Neither source knows about the other's versioning.

The ref: values Pattern

The mechanism is a ref label on a source in the sources array. A source with a ref and no path is not rendered or deployed — it's a carrier. (Add a path and ArgoCD will try to deploy it.) Other sources in the same Application can reference files from it using the $ref-name/ prefix in their valueFiles list.

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app-prod
  namespace: argocd
spec:
  project: my-project
  destination:
    namespace: my-app-prod
    name: in-cluster
  sources:
    - repoURL: 'https://registry.example.com/helm-charts'
      chart: my-app
      targetRevision: 2.1.0
      helm:
        valueFiles:
          - $values/apps/my-app/values-prod.yaml
    - repoURL: 'https://git.example.com/scm/myteam/k8s-config.git'
      targetRevision: main
      ref: values
  syncPolicy:
    automated:
      prune: true
    syncOptions:
      - CreateNamespace=true

The second source — the Git repo — is declared with ref: values and no path, so it is never deployed. Its files become available to the first source as $values/path/to/file. The chart renders with the values from the config repo at sync time.

Always use the $values/ prefix in valueFiles. Without it, ArgoCD looks for the values file inside the chart source — which doesn't have it. The resulting error ("file not found") doesn't always make the cause obvious. The prefix is what tells ArgoCD which source to pull the file from.

Bumping the chart is a one-line change to targetRevision in the Application manifest. Bumping the environment config is a commit to the values Git repo. Neither affects the other. CI publishes chart releases; ops manages configuration changes. They're decoupled because they should be.

Pattern 1: Static Application (Pinned Version)

For production environments, you want explicit control over every version transition. The chart version is pinned. Nothing auto-updates. A deliberate commit to the ArgoCD manifests repo is required to move forward.

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app-prod
  namespace: argocd
spec:
  project: my-project
  destination:
    namespace: my-app-prod
    name: in-cluster
  sources:
    - repoURL: 'https://registry.example.com/helm-charts'
      chart: my-app
      targetRevision: 2.1.0          # pinned — never auto-updated
      helm:
        valueFiles:
          - $values/apps/my-app/values-prod.yaml
    - repoURL: 'https://git.example.com/scm/myteam/k8s-config.git'
      targetRevision: main
      ref: values
  syncPolicy:
    automated:
      prune: true                    # removes resources removed from chart
    syncOptions:
      - CreateNamespace=true

The bump workflow: CI publishes a release chart (e.g., 2.2.0). A pull request updates targetRevision: 2.2.0 in the Application manifest. The PR is reviewed, merged, and ArgoCD picks up the change. The entire promotion is auditable in git history — who changed it, when, and why.

syncPolicy.automated.prune: true is worth noting explicitly. Without it, resources that were in a previous chart version but removed in the new version linger in the cluster. Pruning cleans them up automatically on sync. Enable it for production when you're confident your chart changes are intentional.

Pattern 2: ApplicationSet with Prerelease Tag Plugin

Dev environments should track the latest in-progress chart version without manual intervention. An ApplicationSet generator plugin polls your chart registry for the highest version matching a prerelease tag (e.g., beta) and automatically updates targetRevision.

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: my-app-dev
  namespace: argocd
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]
  generators:
    - plugin:
        configMapRef:
          name: chart-version-plugin
        input:
          parameters:
            chartRepo: "my-helm-charts"
            chartName: "my-app"
            prereleaseTag: "beta"
        requeueAfterSeconds: 30
  template:
    metadata:
      name: "my-app-dev"
    spec:
      project: my-project
      destination:
        namespace: my-app-dev
        name: in-cluster
      sources:
        - repoURL: 'https://registry.example.com/helm-charts'
          chart: my-app
          targetRevision: '{{ .highestTag }}'
          helm:
            valueFiles:
              - $values/apps/my-app/values-dev.yaml
        - repoURL: 'https://git.example.com/scm/myteam/k8s-config.git'
          targetRevision: main
          ref: values
      syncPolicy:
        automated:
          prune: true

The plugin runs every 30 seconds. When CI publishes 2.2.0-beta, the generator detects it, {{ .highestTag }} resolves to 2.2.0-beta, and ArgoCD redeploys the dev environment automatically. No manifest commit required. No human in the loop for dev updates.

The plugin is a custom ArgoCD ApplicationSet generator deployed in the cluster. It queries the Helm registry API, applies the prerelease tag filter, and returns the highest matching version as highestTag. The implementation is straightforward — a small HTTP service with a /api/v1/getparams.execute endpoint registered via a ConfigMap.

Pattern 3: ApplicationSet with Pull Request Generator

For ephemeral per-PR environments, the pull request generator watches your Git server for open pull requests and creates an ArgoCD Application per PR. When the PR closes, the ApplicationSet controller deletes the Application — but not the namespace (see the note below on namespace cleanup).

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: my-app-pr
  namespace: argocd
spec:
  generators:
    - pullRequest:
        bitbucketServer:
          project: MYPROJECT
          repo: my-app-repo
          api: https://git.example.com
          basicAuth:
            username: svc-argocd
            passwordRef:
              secretName: argocd-bitbucket-creds
              key: password
        filters:
          - branchMatch: "(feature|hotfix|bugfix)/.*"
        requeueAfterSeconds: 180
  template:
    metadata:
      name: 'my-app-{{branch_slug}}'
    spec:
      source:
        repoURL: 'https://git.example.com/scm/myteam/my-app.git'
        targetRevision: '{{branch}}'
        path: charts/my-app
        helm:
          parameters:
            - name: "image.tag"
              value: "{{head_sha}}"
      destination:
        namespace: 'my-app-pr-{{number}}'
        name: in-cluster
      project: my-project
      syncPolicy:
        automated:
          prune: true
        syncOptions:
          - CreateNamespace=true

Each open PR matching the branch filter gets its own namespace. The image tag is injected from the PR's head commit SHA — no separate values file needed for this. When the PR merges or closes, the ApplicationSet controller deletes the generated Application, and its deletion finalizer removes everything it deployed — but not a namespace created by CreateNamespace=true, which ArgoCD doesn't track. If you want the namespace gone too, ship the Namespace as a resource in the chart, mark it as owned through managedNamespaceMetadata's tracking annotation, or delete it from a PostDelete hook.

The PR generator requires a service account credential with read access to your Git server. In enterprise environments with token rotation policies, an expired credential silently stops PR environment creation — no error surfaces on the Applications themselves. It shows up on the ApplicationSet instead — its ErrorOccurred condition and the controller log (the alpha ApplicationSet view in ArgoCD 3.5 shows it as Degraded) — so alert on that, monitor the credential expiry, and rotate it before it expires, not after engineers start wondering why their PR environment didn't appear.

The App of Apps (Root Driver)

The Application and ApplicationSet manifests shown above are themselves Kubernetes resources — someone has to apply them. The App of Apps pattern handles this: one root Application watches a directory in your config repo. Any YAML file added to that directory is automatically deployed as an ArgoCD Application.

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root-driver
  namespace: argocd
spec:
  project: platform-admin   # admin-only; app teams never deploy from this project
  source:
    repoURL: 'https://git.example.com/scm/myteam/k8s-config.git'
    targetRevision: main
    path: clusters/my-cluster
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

To add a new application to the cluster: create clusters/my-cluster/my-new-app.yaml, commit, push. The root driver detects the new file and ArgoCD deploys it. The only manual step is bootstrapping the root driver itself — a one-time kubectl apply per cluster. Keep the root driver in its own admin-only project and treat that directory as admin-only: anything merged there can create an Application in any project, and a project that can deploy into the argocd namespace is effectively cluster-admin — restrict push access and review the project field on every PR.

selfHeal: true on the root driver is important. It means ArgoCD continuously reconciles the child Application manifests the root driver manages back to what's in Git, even if someone edits them directly. Without it, a manual edit shows as OutOfSync but stays in place until someone syncs. selfHeal won't remove an Application created by hand outside Git, though — that needs RBAC.

Per-Environment Values Convention

Values files are the per-environment configuration layer. A consistent naming convention makes the system navigable:

File Environment How chart version is set
values-dev.yaml Development Prerelease tag plugin auto-updates to latest beta
values-qa.yaml QA / Staging Manual bump or Kargo promotion from dev
values-rc.yaml Release candidate Manual bump to release candidate version
values-prod.yaml Production Pinned version, manual bump via PR
none Pull request (ephemeral) Chart rendered from the PR branch; head_sha injected as the image tag

Values files typically differ by: replica count, database name or schema, domain/subdomain, resource limits, and feature flags. Everything else inherits chart defaults. Keeping values files minimal — only what differs per environment — reduces the surface area for environment-specific bugs.

What Stays Your Problem

Multi-source handles structure; it doesn't automate promotion. Getting a verified chart version from dev through qa to prod on a controlled schedule is a separate problem. Multi-source gives you the right shape — independently versioned chart and config — but the actual promotion flow (dev → qa → prod) still needs a tool like Kargo or a purpose-built script that opens PRs to bump targetRevision in the right Application manifest at the right time.

The other things that remain your responsibility:

One Pattern, Three Variations

The first two patterns share the same structure — multi-source Helm with $values/ references; the PR pattern renders the chart straight from the PR branch. What changes across all three is who controls targetRevision:

Choosing the right pattern for each environment is the main design decision. Production should never use the prerelease plugin — you want explicit control. Dev should never require manual bumps — it defeats the purpose of a dev environment. QA sits in between: auto-promote from dev via Kargo, but require a human gate before prod.

The config repo becomes the single source of truth for what is deployed where. Every environment's chart version and configuration is readable from one Git repository, without needing to query the cluster or dig through ArgoCD's UI. The history is the audit log. The diff is the change record. This is what GitOps is supposed to be.

Key Takeaways

ArgoCD Helm GitOps ApplicationSet Kubernetes Platform Engineering
Share

Disclaimer: The views and opinions expressed in this post are my own and do not represent those of my employer or any organisation I am associated with.

Comments & Discussion