Security

Install External Secrets Operator and Sync AWS Secrets Manager into Kubernetes

Intermediate25 min to complete16 min read

Install the External Secrets Operator, configure an AWS Secrets Manager SecretStore using IAM Roles for Service Accounts (IRSA), and sync secrets into Kubernetes automatically — so your pods always have the latest credentials without storing anything in Git.

Before you begin

  • An EKS cluster with OIDC provider enabled
  • kubectl configured with cluster-admin access
  • Helm 3 installed
  • AWS CLI configured with admin credentials
  • Your cluster's OIDC issuer URL and ID
External Secrets
AWS
Secrets Manager
IRSA
Kubernetes
Helm
Security

The pattern of storing secrets in Kubernetes Secrets and committing encrypted versions to Git works, but it creates a coupling between your cluster and your secret storage that becomes a liability at scale. External Secrets Operator (ESO) flips this: secrets live in AWS Secrets Manager (or Vault, GCP Secret Manager, etc.), and ESO syncs them into Kubernetes Secrets automatically. Rotation in AWS propagates to the cluster within your configured refresh interval.

This tutorial installs ESO, configures AWS Secrets Manager access via IRSA, and creates an ExternalSecret that syncs into a usable Kubernetes Secret.

Step 1: Find your cluster OIDC information

You'll need these values for IAM trust policies. Replace CLUSTER_NAME and REGION:

bash
1export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
2export AWS_REGION=us-east-1
3export CLUSTER_NAME=my-cluster
4
5# Get the OIDC issuer URL
6export OIDC_URL=$(aws eks describe-cluster \
7  --name $CLUSTER_NAME \
8  --region $AWS_REGION \
9  --query "cluster.identity.oidc.issuer" \
10  --output text)
11
12# Extract just the ID (last path segment)
13export OIDC_ID=$(echo $OIDC_URL | sed 's|.*/||')
14
15echo "OIDC ID: $OIDC_ID"

Step 2: Add the External Secrets Helm repository

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

Step 3: Install the External Secrets Operator

bash
helm install external-secrets external-secrets/external-secrets \
  --namespace external-secrets \
  --create-namespace

Verify all three pods are running:

bash
kubectl get pods -n external-secrets

Expected output:

NAME                                                READY   STATUS
external-secrets-7d9f8c6b5-xk4p2                    1/1     Running
external-secrets-cert-controller-6b9d7b8d8c-r4n7l   1/1     Running
external-secrets-webhook-5c9b9b9b8c-k2j9m            1/1     Running

Step 4: Create an IAM policy for Secrets Manager access

Scope the policy to only the secrets ESO needs to read:

bash
1cat <<EOF > eso-policy.json
2{
3  "Version": "2012-10-17",
4  "Statement": [
5    {
6      "Effect": "Allow",
7      "Action": [
8        "secretsmanager:GetSecretValue",
9        "secretsmanager:DescribeSecret",
10        "secretsmanager:GetResourcePolicy",
11        "secretsmanager:ListSecretVersionIds"
12      ],
13      "Resource": "arn:aws:secretsmanager:${AWS_REGION}:${AWS_ACCOUNT_ID}:secret:prod/*"
14    }
15  ]
16}
17EOF
18
19aws iam create-policy \
20  --policy-name ExternalSecretsPolicy \
21  --policy-document file://eso-policy.json

Step 5: Create an IAM role with IRSA trust

IRSA (IAM Roles for Service Accounts) lets the ESO pod authenticate to AWS via its Kubernetes service account token — no static AWS credentials in the cluster:

bash
1cat <<EOF > eso-trust.json
2{
3  "Version": "2012-10-17",
4  "Statement": [{
5    "Effect": "Allow",
6    "Principal": {
7      "Federated": "arn:aws:iam::${AWS_ACCOUNT_ID}:oidc-provider/oidc.eks.${AWS_REGION}.amazonaws.com/id/${OIDC_ID}"
8    },
9    "Action": "sts:AssumeRoleWithWebIdentity",
10    "Condition": {
11      "StringEquals": {
12        "oidc.eks.${AWS_REGION}.amazonaws.com/id/${OIDC_ID}:sub": "system:serviceaccount:external-secrets:external-secrets",
13        "oidc.eks.${AWS_REGION}.amazonaws.com/id/${OIDC_ID}:aud": "sts.amazonaws.com"
14      }
15    }
16  }]
17}
18EOF
19
20aws iam create-role \
21  --role-name ExternalSecretsRole \
22  --assume-role-policy-document file://eso-trust.json
23
24aws iam attach-role-policy \
25  --role-name ExternalSecretsRole \
26  --policy-arn arn:aws:iam::${AWS_ACCOUNT_ID}:policy/ExternalSecretsPolicy

Step 6: Annotate the ESO service account

Link the Kubernetes service account to the IAM role:

bash
kubectl annotate serviceaccount external-secrets \
  -n external-secrets \
  eks.amazonaws.com/role-arn=arn:aws:iam::${AWS_ACCOUNT_ID}:role/ExternalSecretsRole

Restart the ESO deployment to pick up the new annotation:

bash
kubectl rollout restart deployment/external-secrets -n external-secrets

Step 7: Create a test secret in AWS Secrets Manager

bash
aws secretsmanager create-secret \
  --name prod/app/database \
  --region $AWS_REGION \
  --secret-string '{"username":"appuser","password":"supersecret"}'

Step 8: Create a ClusterSecretStore

A ClusterSecretStore is cluster-scoped and can serve ExternalSecret resources in any namespace:

bash
1cat <<EOF | kubectl apply -f -
2apiVersion: external-secrets.io/v1
3kind: ClusterSecretStore
4metadata:
5  name: aws-secrets-manager
6spec:
7  provider:
8    aws:
9      service: SecretsManager
10      region: ${AWS_REGION}
11      auth:
12        jwt:
13          serviceAccountRef:
14            name: external-secrets
15            namespace: external-secrets  # required for ClusterSecretStore
16EOF

Verify it's connected:

bash
kubectl get clustersecretstore aws-secrets-manager

Expected:

NAME                   AGE   STATUS   CAPABILITIES   READY
aws-secrets-manager    30s   Valid    ReadOnly       True

Step 9: Create an ExternalSecret

This syncs specific keys from the AWS secret into a Kubernetes Secret named app-secrets:

bash
1cat <<EOF | kubectl apply -f -
2apiVersion: external-secrets.io/v1
3kind: ExternalSecret
4metadata:
5  name: app-secrets
6  namespace: default
7spec:
8  refreshInterval: 1h
9  secretStoreRef:
10    name: aws-secrets-manager
11    kind: ClusterSecretStore
12  target:
13    name: app-secrets
14    creationPolicy: Owner
15  data:
16  - secretKey: DB_PASSWORD
17    remoteRef:
18      key: prod/app/database
19      property: password
20  - secretKey: DB_USERNAME
21    remoteRef:
22      key: prod/app/database
23      property: username
24EOF

Step 10: Verify the secret was created

bash
kubectl get secret app-secrets -n default
kubectl get externalsecret app-secrets -n default

Expected output:

NAME          TYPE     DATA   AGE
app-secrets   Opaque   2      30s

NAME          STORE                  REFRESH INTERVAL   STATUS         READY
app-secrets   aws-secrets-manager    1h                 SecretSynced   True

Decode to confirm the values:

bash
kubectl get secret app-secrets -n default \
  -o jsonpath='{.data.DB_PASSWORD}' | base64 -d

Step 11: Test secret rotation

Update the secret in AWS:

bash
aws secretsmanager update-secret \
  --secret-id prod/app/database \
  --region $AWS_REGION \
  --secret-string '{"username":"appuser","password":"newpassword"}'

Force an immediate sync (instead of waiting for the 1-hour refresh):

bash
kubectl annotate externalsecret app-secrets \
  -n default \
  force-sync=$(date +%s) --overwrite

Check the updated value:

bash
kubectl get secret app-secrets -n default \
  -o jsonpath='{.data.DB_PASSWORD}' | base64 -d

What you built

ESO is syncing secrets from AWS Secrets Manager into Kubernetes Secrets automatically. When a secret rotates in AWS, ESO picks up the new value on the next refresh cycle and updates the Kubernetes Secret. Pods consuming the secret via envFrom or volume mounts receive the new value on their next restart. No static AWS credentials in the cluster — authentication is entirely via IRSA.

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.