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.
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 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:
values-dev.yaml, values-prod.yaml alongside chart code. Ops changes and application code changes land in the same pull requests. The git history becomes unreadable.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.
ref: values PatternThe 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.
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.
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.
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 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.
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.
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:
CreateNamespace=true needs one of the options above if you want it removed too.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:
targetRevision via a PRtargetRevision automatically as new beta charts are publishedtargetRevision tracks the PR branch directlyChoosing 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.
ref: values source without a path is a carrier only — it is never deployed, just referenced. Use $values/ prefix in valueFiles to access it.kubectl apply.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.