External Secrets Operator: syncing Vault KV v2 into Kubernetes

Published: 2026-02-13

External Secrets Operator (ESO) bridges HashiCorp Vault and Kubernetes. Instead of sealing secrets manually with kubeseal or templating them in CI, you describe what you want from Vault and ESO creates the Kubernetes Secret automatically, refreshing it on a schedule.

This post covers the full setup: install, ClusterSecretStore configuration, ExternalSecret examples for common patterns (single key, full path extraction, registry credentials), and the debugging workflow when syncs fail.


Install

ESO is deployed via Helm through FluxCD:

yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: external-secrets
spec:
  interval: 1h
  chart:
    spec:
      chart: external-secrets
      version: "0.14.4"
      sourceRef:
        kind: HelmRepository
        name: external-secrets
        namespace: flux-system
  values:
    installCRDs: true
    replicaCount: 1
    serviceMonitor:
      enabled: true
      interval: 60s
      labels:
        release: kube-prom-stack

installCRDs: true is fine for single-cluster installs. For multi-cluster, manage CRDs separately to avoid version conflicts when upgrading ESO across different clusters at different times.

After install, check that the CRDs are present:

bashkubectl get crd | grep external-secrets

You should see clustersecretstores.external-secrets.io, externalsecrets.external-secrets.io, and others.


ClusterSecretStore: connect ESO to Vault

The ClusterSecretStore is a cluster-scoped resource. It holds the connection and auth config for Vault:

yamlapiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: vault-infra
spec:
  provider:
    vault:
      server: "http://vault.vault.svc.cluster.local:8200"
      path: "secret"
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "external-secrets"
          serviceAccountRef:
            name: "external-secrets"
            namespace: "external-secrets"

server — the Vault address. Using the internal service DNS is better than the ingress hostname — no TLS, no external round-trip, no dependency on ingress being up.

path: "secret" — the KV mount path. If you mounted at a different path (e.g., vault secrets enable -path=kv kv-v2), update this.

version: "v2" — must match your KV version. KV v2 and v1 have different API paths; using the wrong version results in "secret not found" errors.

role: "external-secrets" — the Vault Kubernetes auth role that ESO authenticates with.

Create the Vault role

bashvault write auth/kubernetes/role/external-secrets \
  bound_service_account_names=external-secrets \
  bound_service_account_namespaces=external-secrets \
  policies=read-secrets \
  ttl=1h

And the policy:

hcl# read-secrets.hcl
path "secret/data/*" {
  capabilities = ["read"]
}
path "secret/metadata/*" {
  capabilities = ["read", "list"]
}
bashvault policy write read-secrets read-secrets.hcl

ExternalSecret: pull a specific key from Vault

yamlapiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: my-app-db-password
  namespace: app
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-infra
    kind: ClusterSecretStore
  target:
    name: my-app-db-password
    creationPolicy: Owner
  data:
    - secretKey: password           # key name in the K8s Secret
      remoteRef:
        key: secret/data/app/db    # Vault KV v2 path
        property: password          # field name in Vault

ESO creates a Secret named my-app-db-password in the app namespace with data.password populated from Vault. Every refreshInterval it re-reads the value and updates the Secret if it changed.

The KV v2 path format

In KV v2, the API path differs from the CLI path:

  • CLI: vault kv get secret/app/db (no data/ prefix)
  • API/ESO: secret/data/app/db (with data/ prefix)

This trips up almost everyone the first time. If you see "secret not found" from ESO but vault kv get works fine from the command line, it's usually a path format mismatch.


Syncing all keys from a Vault path

If you have a Vault KV path with many fields, use dataFrom instead of enumerating each one:

yamlspec:
  dataFrom:
    - extract:
        key: secret/data/app/config

This creates a Kubernetes Secret with one entry per field in the Vault secret. If the Vault secret has username, password, and host fields, the K8s Secret will have all three.

Remapping key names

If the field names in Vault don't match what your app expects in the Kubernetes Secret:

yamlspec:
  data:
    - secretKey: DB_PASSWORD        # K8s Secret key
      remoteRef:
        key: secret/data/app/db
        property: password          # Vault field name
    - secretKey: DB_HOST
      remoteRef:
        key: secret/data/app/db
        property: host

Syncing Docker registry credentials

Image pull secrets have a special format. ESO can construct them directly:

yamlapiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: registry-creds
  namespace: app
spec:
  refreshInterval: 24h
  secretStoreRef:
    name: vault-infra
    kind: ClusterSecretStore
  target:
    name: registry-creds
    creationPolicy: Owner
    template:
      type: kubernetes.io/dockerconfigjson
      data:
        .dockerconfigjson: |
          {"auths":{"registry.example.com":{"auth":"{{ .auth | b64enc }}"}}}
  data:
    - secretKey: auth
      remoteRef:
        key: secret/data/registry
        property: auth

Or if you store the full dockerconfigjson in Vault:

yamlspec:
  data:
    - secretKey: .dockerconfigjson
      remoteRef:
        key: secret/data/registry
        property: dockerconfigjson
  target:
    template:
      type: kubernetes.io/dockerconfigjson

creationPolicy: Owner vs Merge

Owner (recommended): ESO owns the Secret. If the ExternalSecret is deleted, the Secret is deleted too. The Secret cannot be modified manually — ESO will overwrite changes on the next refresh.

Merge: ESO merges keys into an existing Secret. Useful when a Helm chart creates the Secret and you're only injecting additional keys from Vault. The existing keys in the Secret are preserved.

None: ESO does not manage the Secret lifecycle at all — it only creates it if it doesn't exist, never updates or deletes.


Comparing ESO vs SealedSecrets

ESO SealedSecrets
Secret source Vault, AWS SM, GCP SM, etc. Git (encrypted)
Rotation Automatic on schedule Manual re-seal
Audit trail In Vault In Git
Air-gapped Needs Vault access Git only
Re-seal needed on cluster rebuild No Yes
Works without Vault No Yes
Initial bootstrap Needs Vault + ESO first Works from scratch

We use both: SealedSecrets for bootstrap (kubeconfigs, initial registry creds, Vault init secrets) and ESO for application secrets that live in Vault and rotate frequently. The rule: anything that ESO itself needs to start (Vault address, auth config) should be a SealedSecret; everything else should be an ExternalSecret.


Debugging

bash# Check if ESO successfully synced
kubectl get externalsecret -n app

# See last sync status and error
kubectl describe externalsecret my-app-db-password -n app

# Force immediate re-sync
kubectl annotate externalsecret my-app-db-password \
  force-sync=$(date +%s) -n app --overwrite

The force-sync annotation trick is useful when you've updated a Vault value and don't want to wait for the next refresh interval.

Checking ClusterSecretStore health

bashkubectl get clustersecretstore vault-infra
kubectl describe clustersecretstore vault-infra

A healthy ClusterSecretStore shows Valid: True. If it shows Invalid, the most common causes:

  • Vault is sealed — check vault status
  • Auth role is misconfigured — check with vault read auth/kubernetes/role/external-secrets
  • Wrong Vault address — verify server field in the ClusterSecretStore

Common error messages

Error Cause
could not find key ... property field in remoteRef doesn't exist in the Vault secret
secret not found Wrong path format (forgot data/ for KV v2)
permission denied ESO's Vault role doesn't have read permission for this path
connection refused Vault is sealed or the server URL is wrong

ESO with multiple Vault namespaces

If you're using Vault Enterprise with namespaces, add the namespace to the ClusterSecretStore:

yamlspec:
  provider:
    vault:
      server: "http://vault.vault.svc.cluster.local:8200"
      namespace: "admin/dev"  # Vault Enterprise namespace
      path: "secret"
      version: "v2"

The path is relative to the namespace — don't include the namespace prefix in the path.