ExternalSecret
The ExternalSecret resource is the heart of the External Secrets Operator (ESO). It defines:
- What you want to fetch from the provider (e.g., AWS Secrets Manager)
- Where from (which key, which secret)
- How this becomes a native Secret in Kubernetes
Basic Object Structure
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: my-k8s-secret
namespace: my-namespace
spec:
# Defines how often ESO synchronizes data from the provider to the cluster.
# If the secret changes in the provider, it's updated in K8s at this interval.
# Example: "1h", "15s", "5m", "1h".
refreshInterval: 1m
# Specifies which SecretStore or ClusterSecretStore to use to fetch secrets.
secretStoreRef:
name: aws-secretsmanager
kind: SecretStore # or ClusterSecretStore
# Defines the Kubernetes Secret name to be created and how it will be managed. We'll discuss this further ahead.
target:
name: my-k8s-secret
creationPolicy: Owner
# List of individual keys you want to extract from the provider.
data:
- secretKey: DB_USER # Will be the key name in the kubernetes secret
remoteRef:
key: my-app/credentials # Secret name in the provider (e.g., Secrets Manager)
property: username # Internal key (if it's a JSON or nested structure)
- secretKey: DB_PASS
remoteRef:
key: my-app/credentials
property: password
Regarding creationPolicy, it can be:
- Owner: ESO creates and deletes the Secret according to this object.
- Merge: only updates the defined fields, keeping the others. If the Secret my-k8s-secret already exists with other keys (e.g.,
DB_PASS
, API_KEY), these won't be deleted. Only the DB_USER value will be updated on each sync. DB_PASS existing in the secret to be created won't be modified. - None: If the Secret already exists, ESO tries to read the data and synchronize the defined fields, but doesn't create or overwrite.
Well, what I have in my cluster. Only one ClusterSecretStore
❯ kubectl get clustersecretstores.external-secrets.io
NAME AGE STATUS CAPABILITIES READY
aws-secrets-manager 3d4h Valid ReadWrite True
❯ k get secretstores.external-secrets.io --all-namespaces
No resources found
In AWS I have the following secret, this secret has the condition tag I created.
It has two initial values.
Let's apply the manifest below.
#externalsecret.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-test-app
namespace: default # Namespace
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: my-k8s-secret
creationPolicy: Owner
data:
- secretKey: DB_USER
remoteRef:
key: test/app
property: username
- secretKey: DB_PASS
remoteRef:
key: test/app
property: password
Applying this we have
kubectl apply -f externalsecret.yaml -n default
What we expect is to have the secret named my-k8s-secret with the DB_USER and DB_PASS values set.
❯ k get secrets -n default
NAME TYPE DATA AGE
my-k8s-secret Opaque 2 18m
❯ kubectl get secret my-k8s-secret -n default -o jsonpath="{.data}" | jq
{
"DB_PASS": "cGFzc3dvcmQxMjM=",
"DB_USER": "YWRtaW4="
}
❯ kubectl get secret my-k8s-secret -n default -o jsonpath="{.data.DB_PASS}" | base64 --decode
password123
In other words, we are mapping. Now I'll change the password value to pass12345 directly in AWS. We need to wait 1 minute to see the result.
### Nothing yet..
❯ kubectl get secret my-k8s-secret -n default -o jsonpath="{.data.DB_PASS}" | base64 --decode
password123
# About 1 minute later...
❯ kubectl get secret my-k8s-secret -n default -o jsonpath="{.data.DB_PASS}" | base64 --decode
pass12345
What if I delete the secret?
❯ kubectl delete secrets my-k8s-secret -n default
secret "my-k8s-secret" deleted
# Already created...
❯ kubectl get secrets -n default
NAME TYPE DATA AGE
my-k8s-secret Opaque 2 4s
❯ kubectl get secret my-k8s-secret -n default -o jsonpath="{.data.DB_PASS}" | base64 --decode
pass12345
To delete this secret we need to delete the externalsecret.
❯ k delete externalsecrets.external-secrets.io -n default external-secret-test-app
externalsecret.external-secrets.io "external-secret-test-app" deleted
❯ k get secrets -n default
No resources found in default namespace.
However, we can fetch all values at once, but without mapping them. Apply this manifest.
#externalsecret.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-test-app
namespace: default # Namespace
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: my-k8s-secret
creationPolicy: Owner
dataFrom: # We're fetching all properties from the test/app key
- extract:
key: test/app
However, now we have exactly the property as the key name of the created secret.
❯ kubectl get secret my-k8s-secret -n default -o jsonpath="{.data}" | jq
{
"password": "cGFzczEyMzQ1",
"username": "YWRtaW4="
}
This makes things much easier. If we focus on declaring properties as we want them to appear in the secret, we don't need to do the remapping as we saw earlier. This is the most common scenario and covers about 90% of cases.
Let's delete again to restart with some advanced cases
❯ kubectl delete externalsecrets.external-secrets.io -n default external-secret-test-app
externalsecret.external-secrets.io "external-secret-test-app" deleted
Advanced
Now let's create a new key in AWS Secret Manager called test/app2 with properties port: 22 and url: test.local.
We have two keys test/app and test/app2. The regex for them is ^test/app[0-9]?$
- ^ = start
- test/app = starts with test/app
- [0-9]? → optionally ends with a digit from 0 to 9
- $ = end
Let's apply this manifest.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-mixed # New name!
namespace: default
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: my-k8s-secret
creationPolicy: Owner
dataFrom:
- find:
name:
regexp: ^test/app[0-9]?$
❯ kubectl apply -f externalsecret.yaml
externalsecret.external-secrets.io/external-secret-mixed created
❯ k get secrets -n default
No resources found in default namespace.
❯ kubectl describe externalsecrets.external-secrets.io external-secret-mixed
Name: external-secret-mixed
Namespace: default
Labels: <none>
Annotations: <none>
API Version: external-secrets.io/v1
Kind: ExternalSecret
Metadata:
Creation Timestamp: 2025-07-01T00:26:11Z
Generation: 1
Resource Version: 3346996
UID: c32d52bf-af96-4be0-bbbf-2858175220c9
Spec:
Data From:
Find:
Conversion Strategy: Default
Decoding Strategy: None
Name:
Regexp: ^test/app[0-9]?$
Refresh Interval: 1m
Secret Store Ref:
Kind: ClusterSecretStore
Name: aws-secrets-manager
Target:
Creation Policy: Owner
Deletion Policy: Retain
Name: minha-secret-k8s
Status:
Binding:
Name:
Conditions:
Last Transition Time: 2025-07-01T00:26:11Z
Message: could not get secret data from provider
Reason: SecretSyncedError
Status: False
Type: Ready
Refresh Time: <nil>
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning UpdateFailed 8m24s external-secrets error processing spec.dataFrom[0].find, err: error getting all secrets: operation error Secrets Manager: ListSecrets, https response error StatusCode: 400, RequestID: 5db57613-8f1e-445d-b7d9-86d874a3bad6, api error AccessDeniedException: User: arn:aws:sts::XXXXXXXXXXXXXX:assumed-role/external-secrets/external-secrets-provider-aws is not authorized to perform: secretsmanager:ListSecrets because no identity-based policy allows the secretsmanager:ListSecrets action
See what happened... We don't have permission for ListSecrets, but we put this in the permissions. Actually, the problem is that ListSecrets and ListSecretVersionIds don't accept conditions for tags, so we need to isolate this. If you were broad and removed the conditions, you shouldn't have had problems.
The new policy should look like this.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SecretsManagerAccess",
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:GetResourcePolicy",
"secretsmanager:DescribeSecret",
"secretsmanager:BatchGetSecretValue"
],
"Resource": [
"*"
],
"Condition": {
"StringEquals": {
"secretsmanager:ResourceTag/external-secrets": "true"
}
}
},
{
"Sid": "SecretsManagerList",
"Effect": "Allow",
"Action": [
"secretsmanager:ListSecrets",
"secretsmanager:ListSecretVersionIds"
],
"Resource": [
"*"
]
},
{
"Sid": "SSMParameterStoreAccess",
"Effect": "Allow",
"Action": [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath"
],
"Resource": [
"*"
],
"Condition": {
"StringEquals": {
"ssm:ResourceTag/external-secrets": "true"
}
}
},
{
"Sid": "SSMParameterStoreDescribe",
"Effect": "Allow",
"Action": [
"ssm:DescribeParameters"
],
"Resource": [
"*"
]
}
]
}
And we have a secret of secrets. A single Secret that aggregates everything captured via regex.
❯ k get secrets -n default
NAME TYPE DATA AGE
my-k8s-secret Opaque 2 5s
❯ kubectl get secret my-k8s-secret -n default -o jsonpath="{.data}" | jq
{
"test_app": "eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiJwYXNzMTIzNDUifQ==",
"test_app2": "eyJwb3J0IjoiMjIiLCJ1cmwiOiJ0ZXN0LmxvY2FsIn0="
}
❯ kubectl get secret my-k8s-secret -n default -o jsonpath="{.data.test_app2}" | base64 --decode
{"port":"22","url":"test.local"}
❯ kubectl get secret my-k8s-secret -n default -o jsonpath="{.data.test_app}" | base64 --decode
{"username":"admin","password":"pass12345"}
If you wanted a secret that only gets the properties, you would need to merge this manually.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-mixed # New name!
namespace: default
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: my-k8s-secret
creationPolicy: Owner
data:
- secretKey: username
remoteRef:
key: test/app
property: username
- secretKey: password
remoteRef:
key: test/app
property: password
- secretKey: port
remoteRef:
key: test/app2
property: port
- secretKey: url
remoteRef:
key: test/app2
property: url
Applying this manifest above we would have this.
❯ kubectl get secret my-k8s-secret -n default -o jsonpath="{.data}" | jq
{
"password": "cGFzczEyMzQ1",
"port": "MjI=",
"url": "dGVzdC5sb2NhbA==",
"username": "YWRtaW4="
}
I believe that at some point in the future they will merge json fields, but it could cause confusion if multiple secrets have the same properties.
Template
Just as a secret can have labels, type, annotation, etc. in kubernetes, we can expand the target section a bit more to define the metadata of the secret that will be created. The data comes from the SecretStore, but we can define the rest.
##....
target:
name: my-secret
creationPolicy: Owner
template:
metadata:
labels:
app: my-app
type: kubernetes.io/basic-auth
##...
Now let's get straight to the point: how to structure the secret in AWS Secrets Manager so it works with the kubernetes.io/basic-auth type.
It mandatorily expects:
data:
username: <base64>
password: <base64>
So, in AWS Secrets Manager, you need a secret with this content (in JSON):
{
"username": "admin",
"password": "supersecure"
}
Or declare both properties as we did previously with test/app. We can then use test/app for this scenario.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-auth
namespace: default # Namespace
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: app-auth
creationPolicy: Owner
template:
metadata:
labels:
app: my-app
type: kubernetes.io/basic-auth
dataFrom:
- extract:
key: test/app
Applying this we then have secrets with different types.
❯ k get secrets
NAME TYPE DATA AGE
app-auth kubernetes.io/basic-auth 2 9s
my-k8s-secret Opaque 4 24m
❯ k describe secrets app-auth
Name: app-auth
Namespace: default
Labels: app=my-app
reconcile.external-secrets.io/created-by=7e7c363e3e6e4dc68f4dd649c26d7de4b845e87e58d7b50bce7a9acb
reconcile.external-secrets.io/managed=true
Annotations: reconcile.external-secrets.io/data-hash: 97f42b65dc28d4275985c8d338f8f4cdc8322180a8fd4c43807ec771
Type: kubernetes.io/basic-auth
Data
====
password: 9 bytes
username: 5 bytes
The same applies to other types.
-
type: kubernetes.io/ssh-auth expects:
data:
ssh-privatekey: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVkt... -
type: kubernetes.io/tls expects:
data:
tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t...
tls.key: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVkt... -
type: kubernetes.io/dockerconfigjson expects:
{
"auths": {
"https://index.docker.io/v1/": {
"username": "myuser",
"password": "mypass",
"auth": "bXl1c2VyOm15cGFzcw=="
}
}
}Which would convert to:
data:
.dockerconfigjson: <base64 of the JSON above>
Go Template Engine
It's a feature of the External Secrets Operator that allows you to generate the Kubernetes Secret content using Go templates, instead of just copying values directly from the provider.
You can build files, config YAML, JSON, or whatever you want — using variables that are populated with the fetched secret values. Most of the time we use it in a basic way, but this is very powerful.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-go-template
namespace: default
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
# Usually we use data later, but I'll put it before because the order doesn't matter. This is just to understand it as a value input.
data: # Mapped Data coming from AWS Secret Manager
- secretKey: USERNAME
remoteRef:
key: test/app
property: username
- secretKey: PASSWORD
remoteRef:
key: test/app
property: password
target:
name: my-secret # Name of our secret
creationPolicy: Owner
template:
engineVersion: v2 # This is what we call Go template
data: # We expect to build this, a key called config.yaml that has a yaml representation inside using input values.
config.yaml: |
user: {{ .USERNAME }}
pass: {{ .PASSWORD }}
# Applying
❯ kubectl apply -f externalsecret.yaml
externalsecret.external-secrets.io/external-secret-go-template created
❯ k get secrets
NAME TYPE DATA AGE
app-auth kubernetes.io/basic-auth 2 67m
my-secret Opaque 1 38s # Here!
my-k8s-secret Opaque 4 92m
# Inside config.yaml we expect text in yaml format, but here it only shows the key value in json.
❯ kubectl get secret my-secret -n default -o jsonpath="{.data}" | jq
{
"config.yaml": "dXNlcjogYWRtaW4KcGFzczogcGFzczEyMzQ1Cg=="
}
# If we analyze the content of config.yaml we'll see it's yaml.
❯ kubectl get secret my-secret -n default -o jsonpath="{.data.config\.yaml}" | base64 --decode
user: admin
pass: pass12345
Now let's show the power of this.
Imagine you have an AWS Secrets Manager secret that has this JSON structure (complex, with multiple properties):
{
"username": "admin",
"password": "password123",
"db": {
"host": "db.example.com",
"port": 5432,
"replica": true
},
"features": {
"enableFeatureX": true,
"enableFeatureY": false
}
}
Let's generate a Kubernetes secret with:
- A nicely formatted config.yaml file with conditional values.
- A features.json file generated only if features exists and enables each feature dynamically.
- Using conditional logic and loops.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-advanced
namespace: default
spec:
refreshInterval: 30m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: my-advanced-secret
creationPolicy: Owner
template:
engineVersion: v2
type: Opaque
data:
config.yaml: |
# Database Configuration
username: {{ .USERNAME }}
password: {{ .PASSWORD }}
host: {{ .DB.host }}
port: {{ .DB.port }}
{{- if .DB.replica }}
replica: enabled
{{- else }}
replica: disabled
{{- end }}
features.json: |
{
{{- if .FEATURES }}
"enableFeatureX": {{ .FEATURES.enableFeatureX }},
"enableFeatureY": {{ .FEATURES.enableFeatureY }}
{{- else }}
"enableFeatureX": false,
"enableFeatureY": false
{{- end }}
}
data:
- secretKey: USERNAME
remoteRef:
key: test/app
property: username
- secretKey: PASSWORD
remoteRef:
key: test/app
property: password
- secretKey: DB
remoteRef:
key: test/app
property: db
- secretKey: FEATURES
remoteRef:
key: test/app
property: features
.USERNAME
, .PASSWORD
are simple strings.
.DB
and .FEATURES
are nested objects, and you can access internal properties via {{ .DB.host }}
and {{ .FEATURES.enableFeatureX }}
.
Uses if to activate replica and the features JSON based on the existence and value of the actual data.
Generates multiple files in the Secret (config.yaml and features.json).