1.6 EKS: External Secrets with AWS Secrets Manager
Hardcoding secrets in Kubernetes manifests is a security anti-pattern. The External Secrets Operator syncs secrets from AWS Secrets Manager into Kubernetes, giving you centralized secret management with automatic rotation support.
This is part 1.6 of the EKS Infrastructure Series. We’re building on the cluster from 1.5 Pod Identity.
What We’re Building
By the end of this article, you’ll have:
- External Secrets Operator installed via Helm
- ClusterSecretStore connected to AWS Secrets Manager
- IRSA permissions for secure Secrets Manager access
- Automatic sync of AWS secrets to Kubernetes secrets
The Problem with Kubernetes Secrets
Kubernetes has a built-in Secret resource, but it has limitations:
Secrets are just base64-encoded - Not encrypted by default. Anyone with cluster access can decode them.
No audit trail - Kubernetes doesn’t track who accessed which secrets or when.
Manual rotation - Changing a secret means updating the Kubernetes resource and restarting pods.
Scattered management - Secrets live in the cluster, separate from your other infrastructure secrets.
AWS Secrets Manager solves these problems:
- Secrets are encrypted at rest with KMS
- CloudTrail logs all access
- Automatic rotation with Lambda functions
- Central location for all secrets (not just Kubernetes)
How External Secrets Works
The External Secrets Operator watches for ExternalSecret resources and syncs them to Kubernetes:
- You create a secret in AWS Secrets Manager
- You create an ExternalSecret resource in Kubernetes referencing it
- Operator syncs the value from AWS to a Kubernetes Secret
- Pods use the secret like any normal Kubernetes Secret
- Operator keeps it synced - changes in AWS propagate to Kubernetes
What is AWS Secrets Manager?
AWS Secrets Manager is a managed service for storing sensitive data - database passwords, API keys, tokens. It’s different from other AWS “secret” options:
| Service | Use Case | Rotation | Pricing |
|---|---|---|---|
| Secrets Manager | Application secrets, DB credentials | Built-in automatic | $0.40/secret/month + API calls |
| Parameter Store (Standard) | Config values, non-sensitive data | Manual | Free (up to 10k params) |
| Parameter Store (Advanced) | Large configs, policies | Optional | $0.05/param/month |
Use Secrets Manager when you need:
- Automatic rotation
- Cross-account sharing
- Fine-grained IAM policies per secret
- Audit logging of access
Finding Secrets in the AWS Console
- Go to Secrets Manager in the AWS Console
- Click Secrets in the left navigation
- Secrets are organized by name (using paths like
EKS/prod/database)
The console shows:
- Secret name and description
- Last accessed date
- Rotation status
- Tags for organization
Naming Convention Summary
Before we dive in, here’s how all the names connect. This is critical - mismatched names are the most common cause of sync failures:
| Component | Name Used | Purpose |
|---|---|---|
| Namespace | external-secrets |
Where the operator runs |
| Service Account | external-secrets |
Kubernetes identity for the operator |
| IAM Policy | ExternalSecretsOperatorPolicy |
Grants Secrets Manager access |
| IAM Policy Resource | EKS/* |
Only secrets under this prefix are accessible |
| ClusterSecretStore | aws-secrets-manager |
Referenced by ExternalSecrets |
| AWS Secret Path | EKS/test/demo-secret |
Must match IAM policy prefix |
All ExternalSecrets must reference aws-secrets-manager as the store and use paths starting with EKS/.
Understanding the Components
External Secrets has several moving parts:
External Secrets Operator
The operator runs in your cluster and does the actual syncing. It’s a standard Kubernetes operator pattern:
- Watches for ExternalSecret resources
- Fetches values from external providers (Secrets Manager, Vault, etc.)
- Creates/updates Kubernetes Secrets
Installed via Helm, not as an EKS addon (it’s not AWS-specific).
SecretStore / ClusterSecretStore
Defines how to connect to the external secret provider:
- SecretStore - Namespaced, only ExternalSecrets in that namespace can use it
- ClusterSecretStore - Cluster-wide, any namespace can reference it
We use ClusterSecretStore so any namespace can access AWS Secrets Manager.
ExternalSecret
The resource you create to sync a specific secret:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: my-app-secrets
namespace: my-app
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: my-app-secrets # Name of the K8s Secret to create
data:
- secretKey: database-password # Key in the K8s Secret
remoteRef:
key: EKS/prod/database # Path in Secrets Manager (must match IAM policy prefix)
property: password # JSON property (if secret is JSON)
Project Structure
external-secrets/
├── install-external-secrets.sh # Installation script
├── delete-external-secrets.sh # Cleanup script
├── cluster-secret-store.yaml # ClusterSecretStore definition
└── external-secrets-policy.json # IAM policy for Secrets Manager access
Step 1: Create the IAM Policy
The operator needs permissions to read secrets from Secrets Manager. Create a minimal policy:
external-secrets-policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
],
"Resource": "arn:aws:secretsmanager:*:*:secret:EKS/*"
},
{
"Effect": "Allow",
"Action": [
"secretsmanager:ListSecrets"
],
"Resource": "*"
}
]
}
This policy:
- Allows reading secrets under the
EKS/prefix only (adjust for your naming convention) - Allows listing secrets (needed for discovery)
- Denies access to secrets outside the prefix
Principle of Least Privilege
The resource restriction (EKS/*) is important. Without it, the operator could read any secret in your AWS account. Scope it to only what the cluster needs:
EKS/prod/*- Production cluster secrets onlyEKS/dev/*- Development cluster secrets onlyEKS/myapp/*- Application-specific secrets (still under the EKS prefix)
Step 2: Create the ClusterSecretStore
This connects the operator to AWS Secrets Manager:
cluster-secret-store.yaml
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: aws-secrets-manager
spec:
provider:
aws:
service: SecretsManager
region: us-east-1
auth:
jwt:
serviceAccountRef:
name: external-secrets
namespace: external-secrets
The jwt auth method uses IRSA - the service account’s IAM role provides credentials. No static AWS credentials needed.
Step 3: Create the Installation Script
install-external-secrets.sh
#!/bin/bash
# Configuration
CLUSTER_NAME="my-cluster"
REGION="us-east-1"
NAMESPACE="external-secrets"
echo "Installing External Secrets Operator"
echo "====================================="
echo "Cluster: $CLUSTER_NAME"
echo "Region: $REGION"
echo "Namespace: $NAMESPACE"
echo ""
echo "Checking prerequisites..."
if ! kubectl get nodes &>/dev/null; then
echo "ERROR: Base cluster not found or kubectl not configured"
exit 1
fi
if ! command -v helm &> /dev/null; then
echo "ERROR: Helm is required but not installed"
exit 1
fi
echo "Prerequisites verified"
echo ""
echo "Creating namespace..."
kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
echo "Creating IAM policy for Secrets Manager access..."
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
POLICY_ARN="arn:aws:iam::$ACCOUNT_ID:policy/ExternalSecretsOperatorPolicy"
# Get the directory where this script is located
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# Check if policy exists, create only if it doesn't
if aws iam get-policy --policy-arn $POLICY_ARN &>/dev/null; then
echo "IAM policy already exists"
else
aws iam create-policy \
--policy-name ExternalSecretsOperatorPolicy \
--policy-document file://$SCRIPT_DIR/external-secrets-policy.json
if [[ $? -ne 0 ]]; then
echo "ERROR: Failed to create IAM policy"
exit 1
fi
echo "IAM policy created successfully"
fi
echo ""
echo "Creating IAM service account with IRSA..."
eksctl create iamserviceaccount \
--cluster=$CLUSTER_NAME \
--namespace=$NAMESPACE \
--name=external-secrets \
--attach-policy-arn=$POLICY_ARN \
--override-existing-serviceaccounts \
--region=$REGION \
--approve
if [[ $? -ne 0 ]]; then
echo "ERROR: Failed to create service account"
exit 1
fi
echo "Service account created with proper IAM role"
echo ""
echo "Installing External Secrets Operator via Helm..."
helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets external-secrets/external-secrets \
-n $NAMESPACE \
--set serviceAccount.create=false \
--set serviceAccount.name=external-secrets \
--wait
if [[ $? -ne 0 ]]; then
echo "ERROR: Failed to install External Secrets Operator"
exit 1
fi
echo "Waiting for operator to be ready..."
kubectl wait --for=condition=Available --timeout=300s \
deployment/external-secrets -n $NAMESPACE
kubectl wait --for=condition=Available --timeout=300s \
deployment/external-secrets-webhook -n $NAMESPACE
kubectl wait --for=condition=Available --timeout=300s \
deployment/external-secrets-cert-controller -n $NAMESPACE
echo "Setting up ClusterSecretStore for AWS Secrets Manager..."
kubectl apply -f "$SCRIPT_DIR/cluster-secret-store.yaml"
if [[ $? -ne 0 ]]; then
echo "ERROR: Failed to create ClusterSecretStore"
exit 1
fi
echo ""
echo "SUCCESS! External Secrets Operator installed"
echo "============================================="
echo "Components installed:"
echo " - External Secrets Operator"
echo " - Webhook for validation"
echo " - Certificate controller"
echo " - IAM service account with Secrets Manager permissions"
echo " - ClusterSecretStore for AWS Secrets Manager"
echo ""
echo "Verify with:"
echo " kubectl get pods -n $NAMESPACE"
echo " kubectl get clustersecretstores"
Step 4: Create the Cleanup Script
delete-external-secrets.sh
#!/bin/bash
# Configuration
CLUSTER_NAME="my-cluster"
REGION="us-east-1"
NAMESPACE="external-secrets"
echo "Removing External Secrets Operator"
echo "==================================="
echo "Cluster: $CLUSTER_NAME"
echo "Namespace: $NAMESPACE"
echo ""
read -p "Are you sure? (yes/no): " confirm
if [[ $confirm != "yes" ]]; then
echo "Operation cancelled"
exit 0
fi
echo "Checking for existing ExternalSecrets..."
EXTERNAL_SECRETS=$(kubectl get externalsecrets -A --no-headers 2>/dev/null | wc -l)
if [ "$EXTERNAL_SECRETS" -gt 0 ]; then
echo "WARNING: Found $EXTERNAL_SECRETS ExternalSecret resource(s)"
echo " These will be orphaned. Kubernetes secrets they created will remain."
kubectl get externalsecrets -A
echo ""
fi
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
echo "Deleting ClusterSecretStore..."
kubectl delete -f "$SCRIPT_DIR/cluster-secret-store.yaml" --ignore-not-found=true
echo "Uninstalling External Secrets Operator..."
helm uninstall external-secrets -n $NAMESPACE 2>/dev/null || true
echo "Removing service account and IAM role..."
eksctl delete iamserviceaccount \
--name=external-secrets \
--namespace=$NAMESPACE \
--cluster=$CLUSTER_NAME || true
echo "Removing IAM policy..."
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
aws iam delete-policy \
--policy-arn arn:aws:iam::$ACCOUNT_ID:policy/ExternalSecretsOperatorPolicy \
2>/dev/null || true
echo "Removing namespace and CRDs..."
kubectl delete namespace $NAMESPACE --ignore-not-found=true
kubectl delete crd secretstores.external-secrets.io --ignore-not-found=true
kubectl delete crd externalsecrets.external-secrets.io --ignore-not-found=true
kubectl delete crd clustersecretstores.external-secrets.io --ignore-not-found=true
echo ""
echo "Cleanup complete!"
echo "================="
echo "Removed:"
echo " - External Secrets Operator"
echo " - IAM service account and role"
echo " - IAM policy"
echo " - Namespace and CRDs"
Step 5: Run the Installation
chmod +x install-external-secrets.sh
./install-external-secrets.sh
Expected output:
SUCCESS! External Secrets Operator installed
=============================================
Components installed:
- External Secrets Operator
- Webhook for validation
- Certificate controller
- IAM service account with Secrets Manager permissions
- ClusterSecretStore for AWS Secrets Manager
Verify with:
kubectl get pods -n external-secrets
kubectl get clustersecretstores
Step 6: Verify Installation
Check the operator pods:
kubectl get pods -n external-secrets
Expected output:
NAME READY STATUS RESTARTS AGE
external-secrets-xxxxxxxxx-xxxxx 1/1 Running 0 2m
external-secrets-cert-controller-xxx-xxxxx 1/1 Running 0 2m
external-secrets-webhook-xxxxxxxxx-xxxxx 1/1 Running 0 2m
Check the ClusterSecretStore:
kubectl get clustersecretstores
Expected output:
NAME AGE STATUS CAPABILITIES READY
aws-secrets-manager 2m Valid ReadWrite True
If STATUS shows Valid and READY is True, the connection to AWS Secrets Manager is working.
Step 7: Test with a Real Secret
Let’s create a test secret and sync it to Kubernetes. Pay attention to the naming - the secret path in AWS Secrets Manager must match the IAM policy prefix (EKS/*).
A. Create a Secret in AWS Secrets Manager
aws secretsmanager create-secret \
--name EKS/test/demo-secret \
--secret-string '{"username":"admin","password":"supersecret123"}'
The path EKS/test/demo-secret works because our IAM policy allows EKS/*.
B. Create an ExternalSecret
Now create an ExternalSecret that references our ClusterSecretStore (aws-secrets-manager) and the AWS secret path (EKS/test/demo-secret):
cat <<EOF | kubectl apply -f -
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: demo-secret
namespace: default
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager # Must match ClusterSecretStore name
kind: ClusterSecretStore
target:
name: demo-secret # Name of the K8s Secret to create
data:
- secretKey: username # Key in the K8s Secret
remoteRef:
key: EKS/test/demo-secret # Path in AWS Secrets Manager
property: username # JSON property to extract
- secretKey: password
remoteRef:
key: EKS/test/demo-secret
property: password
EOF
Key naming relationships:
secretStoreRef.name: aws-secrets-manager→ matches the ClusterSecretStore we createdremoteRef.key: EKS/test/demo-secret→ matches the AWS secret path (and IAM policy prefix)target.name: demo-secret→ the Kubernetes Secret that will be created
C. Verify the Sync
# Check ExternalSecret status
kubectl get externalsecret demo-secret
# Check the created Kubernetes Secret
kubectl get secret demo-secret -o jsonpath='{.data.password}' | base64 -d
You should see supersecret123.
D. Clean Up Test Resources
kubectl delete externalsecret demo-secret
aws secretsmanager delete-secret --secret-id EKS/test/demo-secret --force-delete-without-recovery
Common Patterns
Database Credentials
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: postgres-credentials
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: postgres-credentials
data:
- secretKey: POSTGRES_USER
remoteRef:
key: EKS/prod/postgres
property: username
- secretKey: POSTGRES_PASSWORD
remoteRef:
key: EKS/prod/postgres
property: password
API Keys
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: api-keys
spec:
refreshInterval: 30m
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: api-keys
data:
- secretKey: STRIPE_API_KEY
remoteRef:
key: EKS/prod/stripe
property: api_key
- secretKey: SENDGRID_API_KEY
remoteRef:
key: EKS/prod/sendgrid
property: api_key
Entire Secret as JSON
If you want to sync the entire secret without specifying properties:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: app-config
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: app-config
dataFrom:
- extract:
key: EKS/prod/app-config
This creates a Kubernetes Secret with all JSON keys from the AWS secret.
Refresh Intervals and Rotation
The refreshInterval controls how often the operator checks for changes:
| Interval | Use Case |
|---|---|
1h |
Standard secrets, low change frequency |
15m |
Secrets that might rotate |
5m |
High-security secrets, frequent rotation |
0 |
One-time sync, no refresh |
When AWS rotates a secret, the operator picks up the new value on the next refresh. Pods using the secret as environment variables need a restart to see the new value. Pods using it as a mounted file see updates automatically.
Cost Breakdown
| Component | Cost |
|---|---|
| External Secrets Operator | Free (open source) |
| Secrets Manager secrets | $0.40/secret/month |
| Secrets Manager API calls | $0.05/10,000 calls |
For 10 secrets refreshing hourly: ~$4/month + negligible API costs.
Troubleshooting
ExternalSecret Shows “SecretSyncedError”
Check the ExternalSecret status:
kubectl describe externalsecret demo-secret
Common causes:
- IAM permissions - The service account role can’t access the secret
- Secret doesn’t exist - Check the path in Secrets Manager
- Wrong property name - The JSON key doesn’t exist in the secret
ClusterSecretStore Shows “InvalidProviderConfig”
Check the store status:
kubectl describe clustersecretstore aws-secrets-manager
Common causes:
- Service account not found - Check the namespace and name match
- IRSA not configured - Verify the service account has the IAM role annotation
- Region mismatch - The region in the store must match where secrets are stored
Secrets Not Updating
Check the refresh interval and operator logs:
kubectl logs -f deployment/external-secrets -n external-secrets
The operator logs show when it syncs and any errors encountered.
What We’ve Accomplished
You now have:
- External Secrets Operator managing secret sync
- ClusterSecretStore connected to AWS Secrets Manager
- IRSA providing secure, scoped access
- Automatic refresh keeping secrets up to date
Next Steps
With secrets management in place, you need visibility into your cluster. The next article covers CloudWatch Observability - pre-built dashboards for monitoring cluster health, node performance, and container logs.
Next: 1.7 CloudWatch Monitoring - Container Insights and Logs
Questions about secrets management? Reach out on socials.