External Secrets Operator
External Secrets Operator (ESO) fetches credentials from a secret backend or generates them, then writes Kubernetes Secrets. Capsule can provision the ESO resources and distribute the resulting Secrets across a Tenant’s namespaces.
This guide covers three patterns:
| Pattern | How it works |
|---|---|
Shared ClusterSecretStore | Approved Tenants read credentials intended to be shared, using one backend identity. |
Dedicated ClusterSecretStore per Tenant | Capsule generates a store restricted to that Tenant’s namespaces, with a separate backend identity. |
| Generated password per Tenant | ESO creates one source Secret, then Capsule replicates its value into every namespace of that Tenant. |
A namespaced SecretStore is usable in its own namespace. A ClusterSecretStore is cluster-scoped and can be referenced across namespaces, subject to its conditions. Neither resource contains the application credentials itself: an ExternalSecret references the store and describes the target Secret. See ESO’s multi-tenancy guide.
Prerequisites
Run the platform setup as a cluster administrator. You need:
- Capsule with GlobalTenantResource generators and
scope: Tenantsupport. - ESO installed with the
external-secrets.io/v1APIs and thegenerators.external-secrets.io/v1alpha1PasswordAPI. Its controllers must watch the platform and Tenant namespaces and processClusterSecretStoreresources. kubectl, and Python 3 for the password verification example.- For the store examples, a reachable Vault server with a KV v2 engine mounted at
secret, and permission to provision its policies and tokens. Replacehttps://vault.example.comwith your endpoint and configure a trusted CA if needed.
The store examples use Vault to demonstrate backend permissions. The same namespace restrictions apply to other ESO providers, including Azure Key Vault. The password-generation example does not require an external backend or a store.
Example Tenants and namespaces
Save this as tenants.yaml and apply it as a cluster administrator. If these Tenants already exist, add the example label to their current manifests while preserving their owners and other configuration.
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
name: solar
labels:
secrets.example.com/enabled: "true"
spec:
owners:
- kind: Group
name: solar-engineers
---
apiVersion: capsule.clastix.io/v1beta2
kind: Tenant
metadata:
name: wind
labels:
secrets.example.com/enabled: "true"
spec:
owners:
- kind: Group
name: wind-engineers
---
apiVersion: v1
kind: Namespace
metadata:
name: solar-dev
labels:
capsule.clastix.io/tenant: solar
---
apiVersion: v1
kind: Namespace
metadata:
name: solar-prod
labels:
capsule.clastix.io/tenant: solar
---
apiVersion: v1
kind: Namespace
metadata:
name: wind-dev
labels:
capsule.clastix.io/tenant: wind
kubectl apply -f tenants.yaml
kubectl get namespaces -L capsule.clastix.io/tenant
The secrets.example.com/enabled label selects Tenants for the GlobalTenantResources below. The capsule.clastix.io/tenant namespace label identifies their ownership and is protected by Capsule. Tenant owners normally create namespaces through Capsule’s namespace workflow.
Platform namespace and replication permissions
Keep backend authentication credentials and generated source Secrets in a platform-owned namespace, outside any Tenant. Tenant owners must not have access to this namespace or permission to modify cluster-scoped stores and GlobalTenantResources.
Save this as eso-provisioner.yaml:
apiVersion: v1
kind: Namespace
metadata:
name: capsule-secrets-system
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: capsule-eso
namespace: capsule-secrets-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: capsule-eso-generators
namespace: capsule-secrets-system
rules:
- apiGroups: ["external-secrets.io"]
resources: ["externalsecrets"]
verbs: ["get", "list", "create", "patch", "delete"]
- apiGroups: ["generators.external-secrets.io"]
resources: ["passwords"]
verbs: ["get", "list", "create", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: capsule-eso-generators
namespace: capsule-secrets-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: capsule-eso-generators
subjects:
- kind: ServiceAccount
name: capsule-eso
namespace: capsule-secrets-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: capsule-eso-replication
rules:
- apiGroups: ["external-secrets.io"]
resources: ["clustersecretstores"]
verbs: ["get", "list", "create", "patch", "delete"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "create", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: capsule-eso-replication
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: capsule-eso-replication
subjects:
- kind: ServiceAccount
name: capsule-eso
namespace: capsule-secrets-system
kubectl apply -f eso-provisioner.yaml
The GlobalTenantResources use this ServiceAccount through Capsule impersonation. The ClusterRole permits Secret replication into current and future Tenant namespaces, which requires broad Secret access; reserve this identity for the platform. ESO uses its own permissions to read authentication credentials, invoke generators, and write source Secrets.
Secure ClusterSecretStores
A store needs two independent access boundaries:
- Kubernetes namespace access:
spec.conditionsdetermines which namespaces may reference the store. - Backend access: the store’s Vault policy, cloud IAM role, or equivalent determines which remote secrets it may fetch.
All users of a store share its backend permissions. A name such as tenant-solar-vault, or a tenant-specific remoteRef.key in an example, does not restrict users to that path. Someone able to create an ExternalSecret can request a different key within the backend identity’s permissions.
For an existing platform-only store, keep its provider configuration and restrict its conditions to the platform namespace:
spec:
conditions:
- namespaces:
- capsule-secrets-system
Alternatively, a namespace selector using matchExpressions with key capsule.clastix.io/tenant and operator DoesNotExist can admit all namespaces outside Capsule Tenants. An explicit namespace allowlist is narrower. With no conditions, a ClusterSecretStore is usable from all namespaces. Conditions are alternatives: matching any entry grants access, so remove broader entries when restricting an existing store. See ClusterSecretStore conditions.
Use a shared ClusterSecretStore
Use a shared store for credentials that all participating Tenants are allowed to read, such as access to a common package registry. This example admits namespaces belonging to solar and wind.
Provision a Vault identity with a policy allowing only the shared KV v2 path. For example, save this as eso-shared.hcl:
path "secret/data/shared/*" {
capabilities = ["read"]
}
The policy uses the Vault KV v2 read path, which includes the data segment. An ESO remoteRef.key below is relative to the secret mount and omits data.
Using an authenticated Vault CLI and jq, create the policy and store its token directly in Kubernetes:
set -o pipefail
vault policy write eso-shared eso-shared.hcl
vault token create -policy=eso-shared -format=json \
| jq -j '.auth.client_token' \
| kubectl -n capsule-secrets-system create secret generic vault-shared \
--from-file=token=/dev/stdin
Manage the token’s expiration, renewal, and rotation as part of your backend setup. ESO also supports Vault Kubernetes authentication when you want to use dedicated ServiceAccount identities instead.
Save this as shared-store.yaml:
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: shared-vault
spec:
conditions:
- namespaceSelector:
matchExpressions:
- key: capsule.clastix.io/tenant
operator: In
values: ["solar", "wind"]
provider:
vault:
server: https://vault.example.com
path: secret
version: v2
auth:
tokenSecretRef:
name: vault-shared
namespace: capsule-secrets-system
key: token
kubectl apply -f shared-store.yaml
kubectl wait --for=condition=Ready --timeout=120s clustersecretstore/shared-vault
Create a Vault record at secret/shared/registry with a password property using your normal secret-management workflow. A user in an admitted namespace can then apply shared-credential.yaml:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: shared-registry
namespace: solar-dev
spec:
refreshPolicy: Periodic
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: shared-vault
target:
name: shared-registry
creationPolicy: Owner
data:
- secretKey: password
remoteRef:
key: shared/registry
property: password
kubectl apply -f shared-credential.yaml
kubectl -n solar-dev wait --for=condition=Ready --timeout=120s externalsecret/shared-registry
ESO creates the shared-registry Secret in solar-dev. Apply the same ExternalSecret in another admitted namespace to fetch that shared credential there as well. A namespace outside solar and wind cannot use this store, even if its users know the store name.
Create a dedicated ClusterSecretStore per Tenant
For tenant-specific credentials, give each store a different backend identity. This example generates tenant-solar-vault and tenant-wind-vault, with names derived from the Capsule Tenant name.
First provision a separate Vault policy and token for each Tenant. Solar’s eso-tenant-solar.hcl policy is:
path "secret/data/tenants/solar/*" {
capabilities = ["read"]
}
set -o pipefail
vault policy write eso-tenant-solar eso-tenant-solar.hcl
vault token create -policy=eso-tenant-solar -format=json \
| jq -j '.auth.client_token' \
| kubectl -n capsule-secrets-system create secret generic vault-tenant-solar \
--from-file=token=/dev/stdin
Repeat for Wind, using secret/data/tenants/wind/*, policy eso-tenant-wind, and Secret vault-tenant-wind. Do not reuse a token that can read both tenants’ paths. These Kubernetes authentication Secrets hold a token key and remain in the platform namespace. Capsule generates the stores; it does not create Vault policies or tokens.
Save this as tenant-stores.yaml:
apiVersion: capsule.clastix.io/v1beta2
kind: GlobalTenantResource
metadata:
name: tenant-secret-stores
spec:
scope: Tenant
resyncPeriod: 60s
serviceAccount:
name: capsule-eso
namespace: capsule-secrets-system
tenantSelector:
matchLabels:
secrets.example.com/enabled: "true"
resources:
- generators:
- missingKey: error
template: |
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: tenant-{{ $.tenant.metadata.name }}-vault
spec:
conditions:
- namespaceSelector:
matchLabels:
capsule.clastix.io/tenant: {{ $.tenant.metadata.name | quote }}
provider:
vault:
server: https://vault.example.com
path: secret
version: v2
auth:
tokenSecretRef:
name: vault-tenant-{{ $.tenant.metadata.name }}
namespace: capsule-secrets-system
key: token
kubectl apply -f tenant-stores.yaml
kubectl wait --for=condition=Ready --timeout=120s globaltenantresource/tenant-secret-stores
kubectl wait --for=condition=Ready --timeout=120s \
clustersecretstore/tenant-solar-vault clustersecretstore/tenant-wind-vault
scope: Tenant creates one cluster-scoped store per Tenant. The store itself has no metadata.namespace; its authentication Secret reference must include one. Its namespace selector automatically admits new namespaces owned by that Tenant.
After creating secret/tenants/solar/application in Vault with a password property, apply tenant-credential.yaml:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: application-credentials
namespace: solar-dev
spec:
refreshPolicy: Periodic
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: tenant-solar-vault
target:
name: application-credentials
creationPolicy: Owner
data:
- secretKey: password
remoteRef:
key: tenants/solar/application
property: password
kubectl apply -f tenant-credential.yaml
kubectl -n solar-dev wait --for=condition=Ready --timeout=120s \
externalsecret/application-credentials
Check both boundaries when validating this setup: an ExternalSecret in wind-dev referencing tenant-solar-vault should be denied by the store’s namespace conditions. An ExternalSecret in solar-dev using its own store but requesting tenants/wind/application should be denied by Vault. Use a separate test resource and target Secret when checking denied requests, and inspect its conditions with kubectl describe externalsecret.
Generate one password and distribute it to every Tenant namespace
An ESO Password generator produces a new random value each time it is invoked. Referencing the same generator from several ExternalSecrets produces different passwords. To share one credential throughout a Tenant, generate it in one source Secret and replicate that Secret. The generator resource holds the password-generation settings, not the generated password. See generator behavior.
flowchart LR GTR[GlobalTenantResource: scope Tenant] --> Password[Password generator] GTR --> ES[One ExternalSecret per Tenant] Password --> ES ES --> Source[Source Secret in capsule-secrets-system] Source --> Replication[GlobalTenantResource: scope Namespace] Replication --> Dev[Same Secret in solar-dev] Replication --> Prod[Same Secret in solar-prod]
Generate the source Secrets
Save this as tenant-password-sources.yaml. It creates a Password generator and an ExternalSecret for each enabled Tenant, all in capsule-secrets-system:
apiVersion: capsule.clastix.io/v1beta2
kind: GlobalTenantResource
metadata:
name: tenant-password-sources
spec:
scope: Tenant
resyncPeriod: 60s
serviceAccount:
name: capsule-eso
namespace: capsule-secrets-system
tenantSelector:
matchLabels:
secrets.example.com/enabled: "true"
resources:
- generators:
- missingKey: error
template: |
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
metadata:
name: tenant-{{ $.tenant.metadata.name }}-password
namespace: capsule-secrets-system
spec:
length: 32
digits: 6
symbols: 0
noUpper: false
allowRepeat: true
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: tenant-{{ $.tenant.metadata.name }}-password
namespace: capsule-secrets-system
spec:
refreshPolicy: CreatedOnce
target:
name: tenant-{{ $.tenant.metadata.name }}-password
creationPolicy: Owner
template:
engineVersion: v2
mergePolicy: Merge
type: Opaque
metadata:
labels:
secrets.example.com/tenant: {{ $.tenant.metadata.name | quote }}
secrets.example.com/purpose: shared-password
dataFrom:
- sourceRef:
generatorRef:
apiVersion: generators.external-secrets.io/v1alpha1
kind: Password
name: tenant-{{ $.tenant.metadata.name }}-password
kubectl apply -f tenant-password-sources.yaml
kubectl wait --for=condition=Ready --timeout=120s globaltenantresource/tenant-password-sources
kubectl -n capsule-secrets-system wait --for=condition=Ready --timeout=120s \
externalsecret/tenant-solar-password externalsecret/tenant-wind-password
For Solar, ESO creates capsule-secrets-system/tenant-solar-password with a password data key. Wind gets an independent value in tenant-wind-password. A generator reference resolves in the ExternalSecret’s namespace and does not need secretStoreRef.
CreatedOnce avoids scheduled regeneration. Capsule’s resyncPeriod reconciles the ESO resource definitions; it does not request a new password on every pass. However, ESO can sync again if the target Secret changes or is deleted, or if the ExternalSecret is recreated. Treat source recreation as credential rotation and retain a backup if the credential must survive recovery.
The explicit target.template.metadata is significant: it defines the source Secret’s labels instead of inheriting the Capsule management labels from its ExternalSecret. Capsule skips Secrets marked projectcapsule.dev/created-by: resources during replication to prevent loops. Keep the source Secret owned by ESO and its replicas owned by Capsule; do not stamp Capsule’s reserved labels onto the source. ESO’s template metadata behavior controls this distinction.
Replicate the source into all namespaces
Save this as tenant-password-replication.yaml:
apiVersion: capsule.clastix.io/v1beta2
kind: GlobalTenantResource
metadata:
name: tenant-password-replication
spec:
scope: Namespace
resyncPeriod: 60s
serviceAccount:
name: capsule-eso
namespace: capsule-secrets-system
tenantSelector:
matchLabels:
secrets.example.com/enabled: "true"
resources:
- namespacedItems:
- apiVersion: v1
kind: Secret
namespace: capsule-secrets-system
name: "tenant-{{tenant.name}}-password"
optional: false
kubectl apply -f tenant-password-replication.yaml
kubectl wait --for=condition=Ready --timeout=120s globaltenantresource/tenant-password-replication
The namespaced reference selects one source by name for each Tenant. With scope: Namespace and no namespaceSelector, Capsule copies it into every namespace of that Tenant, preserving its name and data. New namespaces receive the existing value on reconciliation. optional: false reports a missing source instead of silently producing no copies.
Capsule’s generator and replication readiness only covers Kubernetes resources. It does not prove that ESO has created the source Secret, which is why the commands wait for the source ExternalSecrets separately. If the two GlobalTenantResources are applied together, replication may initially report a missing Secret and then succeed once ESO creates it.
Verify identical values and tenant isolation
Check that both Solar namespaces have Solar’s Secret and Wind has only its own:
kubectl -n solar-dev get secret tenant-solar-password
kubectl -n solar-prod get secret tenant-solar-password
kubectl -n wind-dev get secret tenant-wind-password
kubectl -n wind-dev get secret tenant-solar-password
The last command should report NotFound. This Python check compares the values without printing passwords or hashes:
import base64
import json
import subprocess
def password(namespace, name):
raw = subprocess.check_output(
["kubectl", "-n", namespace, "get", "secret", name, "-o", "json"]
)
return base64.b64decode(json.loads(raw)["data"]["password"])
solar = password("capsule-secrets-system", "tenant-solar-password")
wind = password("capsule-secrets-system", "tenant-wind-password")
assert len(solar) == 32
assert solar == password("solar-dev", "tenant-solar-password")
assert solar == password("solar-prod", "tenant-solar-password")
assert wind == password("wind-dev", "tenant-wind-password")
assert solar != wind
print("Passwords match within each Tenant and differ between Tenants.")
Create another namespace belonging to Solar and repeat the comparison after replication. The new namespace should receive the same source value; adding a namespace does not invoke the Password generator again.
Workloads consume the local copy through a Secret volume or secretKeyRef. For example, use this fragment in a Pod template in solar-dev or solar-prod:
env:
- name: APPLICATION_PASSWORD
valueFrom:
secretKeyRef:
name: tenant-solar-password
key: password
Register the generated credential with the service that authenticates it. Creating or copying a Secret does not set a database user’s password or otherwise configure that service.
Rotation and lifecycle
For backend-managed values, update the backend record. The Periodic ExternalSecrets fetch it on their next refresh. Backend authentication tokens have their own lifecycle and must be renewed or replaced separately.
For generated passwords, CreatedOnce keeps the source stable during ordinary reconciliation. Recreating the source can produce a different password, and Capsule will then distribute the new value. Coordinate rotation with the authenticating service and all consuming workloads. Applications using environment variables need a restart to load changed Secret values; applications reading Secret volumes need to reload them.
The example uses creationPolicy: Owner, so deleting the source ExternalSecret also makes its source Secret eligible for garbage collection. Removing the Tenant’s opt-in label or deleting its GlobalTenantResources can prune generated objects and replicas under Capsule’s object management rules. Check that replicas are removed and service credentials are revoked during offboarding. Source removal alone is not immediate credential revocation.
For credentials that must survive deleting and recreating an ExternalSecret, ESO documents creationPolicy: Orphan with an immutable target. That changes cleanup and rotation: retained source Secrets need explicit offboarding, and immutable replicas cannot be updated in place. Choose that lifecycle deliberately rather than adding immutability to the replication example unchanged.