Skip to content

How-to Guides

Scope

Task recipes for External Secrets Operator (ESO) 2.x: install and upgrade, provider setup, syncing, templating, generators, PushSecret, multi-tenancy hardening, and troubleshooting. All manifests use the current APIs: external-secrets.io/v1 for ExternalSecret and stores, v1alpha1 for PushSecret and generators. Field defaults and policy values are in Reference; the reasoning behind them is in Explanation.

Commands & Recipes

The sections below are self-contained recipes. Replace names, regions, and hostnames with your own.

Installation

Install from the official chart repository. Pin the chart version so upgrades are deliberate.

helm repo add external-secrets https://charts.external-secrets.io
helm repo update

helm install external-secrets external-secrets/external-secrets \
  --namespace external-secrets --create-namespace \
  --version 2.11.0

# Verify: three Deployments (controller, webhook, cert-controller) should be Ready
kubectl get deploy -n external-secrets
kubectl get crds | grep external-secrets.io

To manage CRDs outside Helm (for example with a GitOps tool), install them with server-side apply, because the bundle exceeds the client-side apply annotation limit:

kubectl apply --server-side \
  -f https://raw.githubusercontent.com/external-secrets/external-secrets/v2.11.0/deploy/crds/bundle.yaml

helm install external-secrets external-secrets/external-secrets \
  -n external-secrets --create-namespace --version 2.11.0 --set installCRDs=false

Upgrade ESO Safely

Only the latest minor gets fixes, and upstream recommends upgrading one minor at a time (for example 2.9 -> 2.10 -> 2.11), reading each release note.

# See what is installed and what is available
helm list -n external-secrets
helm search repo external-secrets/external-secrets --versions | head

# Upgrade to the next minor only
helm upgrade external-secrets external-secrets/external-secrets \
  -n external-secrets --version 2.11.0 --reuse-values

Coming from v0.16 or older

Releases from v0.17.0 onward no longer serve external-secrets.io/v1beta1. Before upgrading past v0.16.x, change every manifest in Git from v1beta1 to v1 (the schema is otherwise the same), let v0.16.2 re-store the objects, then continue upgrading minor by minor.

# Find manifests still on v1beta1 in a GitOps repo
grep -rl "external-secrets.io/v1beta1" ./clusters

# Rewrite them (review the diff before committing)
grep -rl "external-secrets.io/v1beta1" ./clusters | xargs sed -i 's#external-secrets.io/v1beta1#external-secrets.io/v1#'

# Check which versions the API server has stored
kubectl get crd externalsecrets.external-secrets.io -o jsonpath='{.status.storedVersions}'

Run ESO in High Availability

Leader election is off by default. Turn it on whenever you run more than one controller replica, and raise concurrent for throughput.

# values-ha.yaml
replicaCount: 2
leaderElect: true
concurrent: 5
webhook:
  replicaCount: 2
podDisruptionBudget:
  enabled: true
  minAvailable: 1
serviceMonitor:
  enabled: true
helm upgrade external-secrets external-secrets/external-secrets \
  -n external-secrets --version 2.11.0 -f values-ha.yaml

Provider Setup

AWS Secrets Manager

Namespaced store that authenticates with IRSA through a ServiceAccount in the same namespace (the ServiceAccount carries the eks.amazonaws.com/role-arn annotation):

apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
  name: aws-secrets
  namespace: myapp
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets-sa

Cluster-wide variant; cluster-scoped stores must name the ServiceAccount's namespace:

apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: aws-secrets
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets-sa
            namespace: external-secrets

With EKS Pod Identity, associate the IAM role with the ESO controller's own ServiceAccount and omit auth entirely; ESO then uses the SDK default credential chain. serviceAccountRef cannot be combined with Pod Identity. Use service: ParameterStore for SSM Parameter Store.

HashiCorp Vault

Configure Vault's Kubernetes auth method and a read-only policy (run against your Vault; the host is your cluster's API endpoint):

vault auth enable kubernetes
vault write auth/kubernetes/config kubernetes_host="https://<kube-apiserver>:6443"

vault policy write myapp-read - <<'EOF'
path "secret/data/myapp/*" {
  capabilities = ["read"]
}
EOF

vault write auth/kubernetes/role/myapp \
  bound_service_account_names=myapp-eso \
  bound_service_account_namespaces=myapp \
  policies=myapp-read ttl=15m

Then point a SecretStore at it. path is the KV mount and version: v2 handles the data/ prefix, so remote keys are relative to the mount:

apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
  name: vault-backend
  namespace: myapp
spec:
  provider:
    vault:
      server: "https://vault.example.com:8200"
      path: "secret"
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "myapp"
          serviceAccountRef:
            name: myapp-eso

For OpenBao, use the dedicated openbao provider (since 2.7) rather than the Vault provider.

GCP Secret Manager

apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
  name: gcp-secret-manager
  namespace: myapp
spec:
  provider:
    gcpsm:
      projectID: my-project
      auth:
        workloadIdentity:
          serviceAccountRef:
            name: eso-workload-id-sa
          clusterLocation: us-central1
          clusterName: my-gke-cluster

The referenced Kubernetes ServiceAccount must be bound to a GCP service account (or principal) with roles/secretmanager.secretAccessor.

ExternalSecret Definition

Sync individual properties of a JSON secret into a Kubernetes Secret, refreshed hourly:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: db-credentials
  namespace: myapp
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets
    kind: SecretStore
  target:
    name: db-credentials
    creationPolicy: Owner
  data:
    - secretKey: password
      remoteRef:
        key: production/database
        property: password

The same pattern against the Vault store above, mapping two keys:

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: myapp-secret
  namespace: myapp
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: myapp-secret
  data:
    - secretKey: DB_PASSWORD
      remoteRef:
        key: myapp/db
        property: password
    - secretKey: API_KEY
      remoteRef:
        key: myapp/api
        property: key

Extract or Find Many Keys at Once

dataFrom.extract copies every property of one remote secret; dataFrom.find searches by name regex or tags. rewrite renames keys on the way in.

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: app-config
  namespace: myapp
spec:
  secretStoreRef:
    name: aws-secrets
    kind: SecretStore
  target:
    name: app-config
  dataFrom:
    - extract:
        key: production/app-config
    - find:
        name:
          regexp: "^production/feature-flags/.*"
      rewrite:
        - regexp:
            source: "production/feature-flags/(.*)"
            target: "FLAG_$1"

Build a TLS Secret with a Template

Render two provider properties into a typed kubernetes.io/tls Secret (template engine v2):

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: prod-tls
  namespace: myapp
spec:
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: prod-tls
    template:
      type: kubernetes.io/tls
      data:
        tls.crt: "{{ .tlsCert }}"
        tls.key: "{{ .tlsKey }}"
  data:
    - secretKey: tlsCert
      remoteRef:
        key: prod/cert
        property: certificate
    - secretKey: tlsKey
      remoteRef:
        key: prod/cert
        property: private_key

When the manifest is itself rendered by Helm, escape the braces (for example {{ "{{ .tlsCert }}" }} or backtick raw strings) so Helm does not evaluate them. Test templates offline with the esoctl template command from cmd/esoctl in the upstream repo.

Generate Credentials with a Generator

An ECR pull secret that refreshes before the 12-hour token expiry:

apiVersion: generators.external-secrets.io/v1alpha1
kind: ECRAuthorizationToken
metadata:
  name: ecr-gen
  namespace: myapp
spec:
  region: eu-west-1
  auth:
    jwt:
      serviceAccountRef:
        name: ecr-pull-sa
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: ecr-secret
  namespace: myapp
spec:
  refreshInterval: 1h
  target:
    name: ecr-secret
    template:
      type: kubernetes.io/dockerconfigjson
      data:
        .dockerconfigjson: |
          {"auths": {"{{ .proxy_endpoint | replace "https://" "" }}": {"auth": "{{ printf "%s:%s" .username .password | b64enc }}"}}}
  dataFrom:
    - sourceRef:
        generatorRef:
          apiVersion: generators.external-secrets.io/v1alpha1
          kind: ECRAuthorizationToken
          name: ecr-gen

A random password that is generated once and never rotated by ESO (every refresh of a generator produces a new value, so use CreatedOnce):

apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
  name: db-password
  namespace: myapp
spec:
  length: 32
  digits: 5
  symbols: 5
  noUpper: false
  allowRepeat: true
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: db-bootstrap-password
  namespace: myapp
spec:
  refreshPolicy: CreatedOnce
  target:
    name: db-bootstrap-password
    creationPolicy: Orphan
    immutable: true
  dataFrom:
    - sourceRef:
        generatorRef:
          apiVersion: generators.external-secrets.io/v1alpha1
          kind: Password
          name: db-password

Push a Secret to a Provider with PushSecret

Copy a cert-manager-issued certificate into Vault; keep the Vault copy if the PushSecret is deleted:

apiVersion: external-secrets.io/v1alpha1
kind: PushSecret
metadata:
  name: push-app-tls
  namespace: myapp
spec:
  refreshInterval: 1h
  updatePolicy: Replace
  deletionPolicy: None
  secretStoreRefs:
    - name: vault-backend
      kind: SecretStore
  selector:
    secret:
      name: app-tls
  data:
    - match:
        secretKey: tls.crt
        remoteRef:
          remoteKey: myapp/tls
          property: certificate

The referenced store needs write permission in the provider (for Vault, a policy with create and update on the path). Check that the provider supports PushSecret in the provider table.

Distribute a Secret to Many Namespaces

ClusterExternalSecret stamps an ExternalSecret into every namespace whose labels match:

apiVersion: external-secrets.io/v1
kind: ClusterExternalSecret
metadata:
  name: registry-pull-secret
spec:
  externalSecretName: registry-pull-secret
  namespaceSelectors:
    - matchLabels:
        registry-access: "true"
  refreshTime: 1m
  externalSecretSpec:
    secretStoreRef:
      name: aws-secrets
      kind: ClusterSecretStore
    refreshInterval: 1h
    target:
      name: registry-pull-secret
    dataFrom:
      - extract:
          key: shared/registry
kubectl label namespace myapp registry-access=true
kubectl get clusterexternalsecret registry-pull-secret -o yaml | grep -A5 provisionedNamespaces

Restrict a ClusterSecretStore to Selected Namespaces

Without conditions, any namespace can read everything the store's credentials can read.

apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
  name: restricted-store
spec:
  conditions:
    - namespaces:
        - "team-a"
        - "team-b"
    - namespaceSelector:
        matchLabels:
          eso-tier: shared
    - namespaceRegexes:
        - "^team-c-.*$"
  provider:
    vault:
      server: "https://vault.example.com"
      path: "secret"
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "eso-role"
          serviceAccountRef:
            name: eso-vault-sa
            namespace: external-secrets

Restrict Who Can Create Stores and PushSecrets

Give developers ExternalSecret rights in their namespace but only read access to stores and PushSecrets:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: external-secret-developer
  namespace: my-app
rules:
  - apiGroups: ["external-secrets.io"]
    resources: ["externalsecrets"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  - apiGroups: ["external-secrets.io"]
    resources: ["secretstores", "pushsecrets"]
    verbs: ["get", "list", "watch"]

Also disable kinds you do not use, both the CRD and its reconciler:

# values-hardened.yaml
processClusterPushSecret: false
processPushSecret: false
crds:
  createClusterPushSecret: false
  createPushSecret: false
rbac:
  serviceAccountTokenCreate: false
webhook:
  extraArgs:
    tls-ciphers: "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"

With rbac.serviceAccountTokenCreate: false, grant serviceaccounts/token create to the ESO ServiceAccount per target ServiceAccount with a Role that uses resourceNames.

Install ESO Namespace-Scoped

For a single-tenant install that cannot touch other namespaces (cluster kinds are implicitly disabled):

helm install external-secrets external-secrets/external-secrets \
  -n team-a --version 2.11.0 \
  --set scopedNamespace=team-a \
  --set scopedRBAC=true

Verify the ESO Image Signature

Images are signed keylessly with Cosign from the upstream release workflow:

cosign verify ghcr.io/external-secrets/external-secrets:v2.11.0 \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-identity-regexp '^https://github.com/external-secrets/external-secrets/.github/workflows/release.yml@.*$'

Force a Refresh and Roll Workloads

ESO updates the Secret, but pods that read it as environment variables keep the old value until restarted.

# Trigger an immediate re-sync of one ExternalSecret
kubectl annotate externalsecret myapp-secret -n myapp force-sync=$(date +%s) --overwrite

# Restart consumers manually
kubectl rollout restart deployment/myapp -n myapp

To automate restarts, run a controller such as Stakater Reloader and annotate the Deployment with reloader.stakater.com/auto: "true". Volume-mounted Secrets are refreshed by the kubelet after its sync period, but the application must re-read the file.

Common Issues

Issue Diagnosis Fix
Secret not syncing kubectl describe externalsecret <name> shows SecretSyncedError Check the store status and the event message
Store not ready kubectl get secretstore,clustersecretstore -A shows Ready=False Fix provider endpoint, CA bundle, or credentials; flood gate skips dependent ExternalSecrets until fixed
Auth failure ESO controller logs Verify IRSA/Pod Identity/Workload Identity binding and the role or policy
no matches for kind ... in version external-secrets.io/v1beta1 Manifests still on v1beta1 Change apiVersion to external-secrets.io/v1
CRD apply fails with "metadata.annotations: Too long" Client-side apply Use kubectl apply --server-side
SecretOwnedByOther Two ExternalSecrets target the same Secret with Owner Give each a unique target.name, or use Merge
Refresh not working refreshPolicy or refreshInterval refreshInterval: 0s or CreatedOnce/OnChange never re-sync periodically; check syncWindows too
Provider throttling externalsecret_provider_api_calls_count rising, 429 errors Lengthen refreshInterval, use dataFrom.extract instead of many data entries
Warning about unmaintained provider Admission warning or events Expected for providers without a maintainer; annotate the store with external-secrets.io/ignore-maintenance-checks: "true" to silence events

Debugging

# Sync status across the cluster
kubectl get externalsecret -A
kubectl get pushsecret -A
kubectl describe externalsecret myapp-secret -n myapp

# Events for ESO objects
kubectl get events -n myapp --field-selector involvedObject.kind=ExternalSecret

# Controller logs (raise verbosity with --set log.level=debug)
kubectl logs -n external-secrets deploy/external-secrets --since=15m

# Webhook and cert-controller health
kubectl logs -n external-secrets deploy/external-secrets-webhook --since=15m
kubectl get validatingwebhookconfigurations | grep external-secrets

Sources