Skip to main content

ExternalSecret

El recurso ExternalSecret es el corazón del External Secrets Operator (ESO). Es él quien dice:

  • Qué quieres buscar en el provider (ej: AWS Secrets Manager)
  • De dónde (qué clave, qué secret)
  • Cómo eso se convierte en un Secret nativo en Kubernetes

Estructura Básica del Objeto

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mi-secret-k8s
namespace: mi-namespace
spec:
# Define con qué frecuencia el ESO sincroniza los datos del provider al cluster.
# Si el secret cambia en el provider, se actualiza en K8s en este intervalo.
# Ejemplo: "1h", "15s", "5m", "1h".
refreshInterval: 1m
# Dice qué SecretStore o ClusterSecretStore usar para buscar los secrets.
secretStoreRef:
name: aws-secretsmanager
kind: SecretStore # o ClusterSecretStore
# Define el nombre del Secret de Kubernetes que será creado y cómo será gestionado. Hablaremos más adelante
target:
name: mi-secret-k8s
creationPolicy: Owner
# Lista de claves individuales que quieres extraer del provider.
data:
- secretKey: DB_USER # Será el nombre de la clave en el secret de kubernetes
remoteRef:
key: my-app/credentials # Nombre secret en el provider (ej: Secrets Manager)
property: username # clave interna (si es un JSON o estructura anidada)
- secretKey: DB_PASS
remoteRef:
key: my-app/credentials
property: password

Hablando un poco de creationPolicy, puede ser:

  • Owner: ESO crea y borra el Secret conforme a este objeto.
  • Merge: actualiza solo los campos definidos, manteniendo los otros. Si ya existe el Secret mi-secret-k8s con otras claves (ej: DB_PASS, API_KEY), estas no serán borradas. Solo el valor de DB_USER será actualizado en cada sync. DB_PASS existiendo en el secret que será creado no será alterado.
  • None: Si el Secret ya existe, ESO intenta leer los datos y sincronizar los campos definidos, pero no crea ni sobrescribe.

Bueno, lo que tengo en mi cluster. Solamente un 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

En AWS tengo el siguiente secret, este secret posee la tag de la condición que creé.

alt text

Ella posee dos valores iniciales.

alt text

Vamos a aplicar el manifiesto abajo.

#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: mi-secret-k8s
creationPolicy: Owner
data:
- secretKey: DB_USER
remoteRef:
key: test/app
property: username
- secretKey: DB_PASS
remoteRef:
key: test/app
property: password

Aplicando esto tenemos

kubectl apply -f externalsecret.yaml -n default

Lo que esperamos es tener el secret llamado mi-secret-k8s con los valores DB_USER y DB_PASS configurados.

❯ k get secrets -n default
NAME TYPE DATA AGE
mi-secret-k8s Opaque 2 18m

❯ kubectl get secret mi-secret-k8s -n default -o jsonpath="{.data}" | jq

{
"DB_PASS": "cGFzc3dvcmQxMjM=",
"DB_USER": "YWRtaW4="
}

❯ kubectl get secret mi-secret-k8s -n default -o jsonpath="{.data.DB_PASS}" | base64 --decode
password123

O sea, estamos mapeando, ahora voy a cambiar el valor de password para pass12345 directo en AWS. Necesitamos esperar 1 minuto para ver el resultado.

### Nada aún..
❯ kubectl get secret mi-secret-k8s -n default -o jsonpath="{.data.DB_PASS}" | base64 --decode
password123
# Unos 1 minuto después...
❯ kubectl get secret mi-secret-k8s -n default -o jsonpath="{.data.DB_PASS}" | base64 --decode
pass12345

¿Si borro el secret?

❯ kubectl delete secrets mi-secret-k8s -n default
secret "mi-secret-k8s" deleted

# Ya lo creó...
❯ kubectl get secrets -n default
NAME TYPE DATA AGE
mi-secret-k8s Opaque 2 4s

❯ kubectl get secret mi-secret-k8s -n default -o jsonpath="{.data.DB_PASS}" | base64 --decode
pass12345

Para que podamos borrar este secret necesitamos borrar el 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.

Pero podemos tomar de una vez todos los valores, pero sin mapearlos. Aplica este manifiesto.

#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: mi-secret-k8s
creationPolicy: Owner
dataFrom: # Estamos tomando todas las propiedades de la clave test/app
- extract:
key: test/app

Pero ahora tenemos exactamente la propiedad como el nombre de la clave del secret creado.

❯ kubectl get secret mi-secret-k8s -n default -o jsonpath="{.data}" | jq

{
"password": "cGFzczEyMzQ1",
"username": "YWRtaW4="
}

Esto facilita mucho. Si nos enfocamos en declarar las propiedades como queremos que aparezcan en el secret no necesitamos hacer el remap como vimos anteriormente. Este es el escenario más común y cubre cerca del 90% de los casos.

Vamos a borrar nuevamente para recomenzar unos casos avanzados

❯ kubectl delete externalsecrets.external-secrets.io -n default external-secret-test-app
externalsecret.external-secrets.io "external-secret-test-app" deleted

Avanzado

Ahora vamos a crear una nueva clave en AWS Secret Manager llamada test/app2 con las propiedades port: 22 y url: test.local.

Tenemos dos claves test/app y test/app2. El regex para ellas es ^test/app[0-9]?$

  • ^ = inicio
  • test/app = comienza con test/app
  • [0-9]? → opcionalmente termina con un dígito de 0 a 9
  • $ = fin

Vamos a aplicar este manifiesto.

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-mixed # ¡Nuevo nombre!
namespace: default
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: mi-secret-k8s
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: mi-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

Mira lo que pasó... No tenemos permiso para ListSecrets, pero lo colocamos en los permisos. En realidad el problema es que ListSecrets y ListSecretVersionIds no aceptan condiciones para tags, entonces necesitamos aislar esto. Si fuiste abarcador y removiste las condiciones no deberías haber tenido problemas.

La nueva política debería quedar así.

{
"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": [
"*"
]
}
]
}

Y tenemos un secret de secrets. Un único Secret que agrega todo lo que fue capturado vía regex.

❯ k get secrets -n default
NAME TYPE DATA AGE
mi-secret-k8s Opaque 2 5s

❯ kubectl get secret mi-secret-k8s -n default -o jsonpath="{.data}" | jq
{
"test_app": "eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiJwYXNzMTIzNDUifQ==",
"test_app2": "eyJwb3J0IjoiMjIiLCJ1cmwiOiJ0ZXN0LmxvY2FsIn0="
}

❯ kubectl get secret mi-secret-k8s -n default -o jsonpath="{.data.test_app2}" | base64 --decode
{"port":"22","url":"test.local"}

❯ kubectl get secret mi-secret-k8s -n default -o jsonpath="{.data.test_app}" | base64 --decode

{"username":"admin","password":"pass12345"}

Si quisieras que un secret tomara solo las propiedades necesitarías mergear eso manualmente.

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-secret-mixed # ¡Nuevo nombre!
namespace: default
spec:
refreshInterval: 1m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: mi-secret-k8s
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

Aplicando ese manifiesto arriba tendríamos esto.

❯ kubectl get secret mi-secret-k8s -n default -o jsonpath="{.data}" | jq

{
"password": "cGFzczEyMzQ1",
"port": "MjI=",
"url": "dGVzdC5sb2NhbA==",
"username": "YWRtaW4="
}

Creo que en algún momento en el futuro ellos harán el merge de json fields pero podría causar confusión si en varios secrets hay las mismas propiedades.

Template

Así como un secret puede tener labels, type, annotation, etc en kubernetes, podemos expandir un poco más la parte de target para definir los metadatos del secret que será creado. El data viene del SecretStore, pero el resto podemos definir.

##....
target:
name: mi-secret
creationPolicy: Owner
template:
metadata:
labels:
app: mi-app
type: kubernetes.io/basic-auth
##...

Ahora vamos directo al punto: cómo estructurar el secret en AWS Secrets Manager para que funcione con el tipo kubernetes.io/basic-auth.

Obligatoriamente espera:

data:
username: <base64>
password: <base64>

Entonces, en AWS Secrets Manager, necesitas un secret con este contenido (en JSON):

{
"username": "admin",
"password": "supersecure"
}

O declarar las dos propiedades como hicimos anteriormente con test/app. Podemos entonces utilizar test/app para este escenario.

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

Aplicando esto tenemos entonces secrets con types diferentes.

❯ k get secrets
NAME TYPE DATA AGE
app-auth kubernetes.io/basic-auth 2 9s
mi-secret-k8s 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

Lo mismo vale para otros types.

  • type: kubernetes.io/ssh-auth espera:

    data:
    ssh-privatekey: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVkt...
  • type: kubernetes.io/tls espera:

    data:
    tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t...
    tls.key: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVkt...
  • type: kubernetes.io/dockerconfigjson espera:

    {
    "auths": {
    "https://index.docker.io/v1/": {
    "username": "myuser",
    "password": "mypass",
    "auth": "bXl1c2VyOm15cGFzcw=="
    }
    }
    }

    Que se convertiría en:

    data:
    .dockerconfigjson: <base64 del JSON arriba>

Go Template Engine

Es una funcionalidad del External Secrets Operator que te permite generar el contenido del Secret Kubernetes usando templates Go, en vez de solo copiar los valores directo del provider.

Puedes montar archivos, config YAML, JSON, o lo que quieras — usando variables que son rellenadas con los valores secretos buscados. La gran mayoría de las veces usamos de forma básica pero esto es muy poderoso.

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
# Generalmente usamos el data después, pero voy a colocar antes pues el orden no importa. Es solo para entender esto como una entrada de valores.
data: # Datos Mapeados Que vienen del AWS Secret Manager
- secretKey: USERNAME
remoteRef:
key: test/app
property: username
- secretKey: PASSWORD
remoteRef:
key: test/app
property: password
# Vamos
target:
name: mi-secret # Nombre de nuestro secret
creationPolicy: Owner
template:
engineVersion: v2 # Este es el que llamamos Go template
data: # Esperamos montar esto, una clave llamada config.yaml que posee una representación yaml dentro usando valores de entrada.
config.yaml: |
user: {{ .USERNAME }}
pass: {{ .PASSWORD }}
# Aplicando
❯ 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
mi-secret Opaque 1 38s # ¡Aquí!
mi-secret-k8s Opaque 4 92m

# Dentro de config.yaml esperamos un texto en formato yaml, pero aquí muestra solo la clave valor en json.
❯ kubectl get secret mi-secret -n default -o jsonpath="{.data}" | jq

{
"config.yaml": "dXNlcjogYWRtaW4KcGFzczogcGFzczEyMzQ1Cg=="
}

# Si analizamos el contenido de config.yaml veremos que es un yaml.
❯ kubectl get secret mi-secret -n default -o jsonpath="{.data.config\.yaml}" | base64 --decode

user: admin
pass: pass12345

Ahora vamos a mostrar el tamaño del poder de esto.

Imagina que tienes un secreto AWS Secrets Manager que tiene esta estructura JSON (compleja, con múltiples propiedades):

{
"username": "admin",
"password": "senha123",
"db": {
"host": "db.example.com",
"port": 5432,
"replica": true
},
"features": {
"enableFeatureX": true,
"enableFeatureY": false
}
}

Vamos a generar un secret Kubernetes con:

  • Un archivo config.yaml formateado bonito, con valores condicionales.
  • Un archivo features.json generado solo si features existe y habilita cada feature dinámicamente.
  • Usar lógica condicional y 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: mi-secret-avanzado
creationPolicy: Owner
template:
engineVersion: v2
type: Opaque
data:
config.yaml: |
# Configuración de la Base de Datos
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 son strings simples.

.DB y .FEATURES son objetos anidados, y puedes acceder propiedades internas vía {{ .DB.host }} y {{ .FEATURES.enableFeatureX }}.

Usa if para activar la replica y el JSON de features basado en la existencia y valor de los datos reales.

Genera múltiples archivos en el Secret (config.yaml y features.json).