Back to posts
Post

Inject Helm Values from SOPS Encrypted Secrets in Argo CD

Learn how to securely inject encrypted Helm values using SOPS and Argo CD's Helm plugin without exposing secrets in Git.

DevopsArgo CDSOPSHelmKubernetesAge

If you're managing Helm charts in Git and need to keep sensitive values out of plain text, SOPS is a solid choice. I've used it for encrypting Kubernetes secrets, and it works just as well for Helm values. The trick is getting Argo CD to decrypt those values at sync time without exposing keys or breaking automation.

In my setup, I store values.yaml.sops in the Helm chart repository, encrypted with Age via a SOPS key tied to our CI/CD service account. Argo CD doesn’t natively understand SOPS, but its Helm plugin supports external value sources through the helm.values parameter with a file path — and that’s where we can inject a decryption step.

First, make sure the SOPS decryption key is available to the Argo CD application controller. I inject it as a Kubernetes Secret mounted into the argocd-application-controller pod:

kubectl create secret generic sops-age-key \
  --from-file=keys.txt=/path/to/age/key.txt \
  -n argocd

Then patch the controller to mount it:

kubectl patch deployment argocd-application-controller \
  -n argocd \
  --type='json' \
  -p='[{"op":"add","path":"/spec/template/spec/volumes/-","value":{"name":"sops-key","secret":{"secretName":"sops-age-key"}}},{"op":"add","path":"/spec/template/spec/containers/0/volumeMounts/-","value":{"name":"sops-key","mountPath":"/sops/keys","readOnly":true}}]'

Set the SOPS age key file via environment variable:

kubectl set env deployment/argocd-application-controller \
  SOPS_AGE_KEY_FILE=/sops/keys/keys.txt \
  -n argocd

Now Argo CD can decrypt .sops files when SOPS is invoked. Next, configure the Helm chart to use the decrypted values. In your Application manifest, point to the encrypted file but let Argo CD’s Helm plugin process it via a shell wrapper:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app
spec:
  source:
    helm:
      valueFiles:
        - values.yaml.sops
      parameters:
        - name: values.yaml.sops
          value: "$(sops -d {{ .ValuesFile }})"

Wait — that won’t work directly because Helm doesn’t evaluate shell commands in valueFiles. Instead, I use a pre-sync hook to decrypt the file temporarily:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
  source:
    path: charts/my-app
    helm:
      valueFiles:
        - values.yaml.decrypted  # generated by hook

And the hook (as a ConfigMap or inline):

apiVersion: v1
kind: ConfigMap
metadata:
  name: decrypt-values-hook
  labels:
    app.kubernetes.io/part-of: my-app
data:
  decrypt.sh: |
    #!/usr/bin/env bash
    set -euo pipefail
    SOPS_AGE_KEY_FILE=/sops/keys/keys.txt sops -d charts/my-app/values.yaml.sops > charts/my-app/values.yaml.decrypted

Then mount this ConfigMap into a temporary job or initContainer — though for simplicity, I often rely on a modified Argo CD image with SOPS baked in and a wrapper script that preprocesses valueFiles before Helm runs.

The cleaner approach? Use the helm.values parameter with a file:// URL and an external decryption step via a plugin. But given Argo CD’s current plugin model, the most reliable method I’ve found is preprocessing values in a Presync hook that runs with access to the SOPS key, writing a plaintext values.yaml.decrypted into the chart directory — which Helm then reads normally.

Once decrypted, Argo CD proceeds with the Helm sync as usual. The decrypted file is ephemeral — it lives only in the container during the sync and isn’t committed.

I’ve seen teams try to use jsonnet or helmfile for this, but if you’re already on Argo CD + Helm, adding a Presync hook keeps things minimal. Just remember: the SOPS key must be tightly scoped. I restrict it to read-only, mount it only in the controller, and audit access via Kubernetes RBAC.

As I mentioned before in my post on [encrypting Kubernetes secrets with SOPS and Age](https://furkanikkan.com/urun/kubernetes-secretlerini-git-te-sops-ve-age-ile-sifreleme-69), the same key management principles apply here — rotate the Age key periodically and avoid storing it in Git.

One gotcha: if your Helm chart uses --set or --values flags elsewhere in the Argo CD config, ensure they don’t override the decrypted file. I stick to valueFiles in the Application spec to keep precedence predictable.

This pattern lets you keep values.yaml.sops in Git — reviewable, diffable, auditable — while ensuring secrets never appear in plain text in your repository or Argo CD logs. It’s not magic, but it’s practical ops: encrypt what matters, decrypt only when and where you need it, and let the toolchain handle the rest.


Cover image: HD Wallpapers · CC0 (Openverse / kamu malı) · https://stocksnap.io/photo/light-abstract-V9L6XXK3LB