external-secrets Wasn't Syncing from Azure Key Vault
external-secrets Wasn't Syncing from Azure Key Vault
We use external-secrets-operator to sync secrets from Azure Key Vault into Kubernetes. It usually works fine. You update a secret in Key Vault, and it shows up in Kubernetes automatically.
Except last week it didn't. I changed a database password in Key Vault, waited 10 minutes, and the pods still had the old password. Took me 2 hours to figure out why.
The setup
We store all secrets in Azure Key Vault. External-secrets-operator runs in the cluster and creates Kubernetes Secret objects from Key Vault secrets.
The configuration looks like this:
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: azure-backend
namespace: default
spec:
provider:
azurekv:
authType: ManagedIdentity
vaultUrl: https://our-vault.vault.azure.net
identityId: <managed-identity-client-id>
Then an ExternalSecret resource that references a Key Vault secret:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: database-credentials
namespace: default
spec:
refreshInterval: 1h # this was the problem
secretStoreRef:
name: azure-backend
kind: SecretStore
target:
name: database-credentials
data:
- secretKey: password
remoteRef:
key: postgresql-password
External-secrets polls Azure Key Vault every refreshInterval and updates the Kubernetes Secret if the value changed.
The problem
I rotated the PostgreSQL password in Key Vault at 2 PM. By 2:15 PM, pods should have had the new credentials. They didn't.
Checked the ExternalSecret status:
kubectl describe externalsecret database-credentials
The status showed last sync was at 1:30 PM. Next sync was scheduled for 2:30 PM.
The refreshInterval was set to 1 hour. So even though the secret changed in Key Vault, external-secrets wouldn't check until the next hour mark. I just had to wait. Kind of frustrating when you're staring at it.
The fix
Changed refreshInterval to something shorter:
spec:
refreshInterval: 5m # check every 5 minutes
But this doesn't trigger an immediate sync. I still had to wait until the next scheduled refresh (at 2:30 PM) for it to pick up the new interval.
To force an immediate refresh, I deleted and recreated the ExternalSecret:
kubectl delete externalsecret database-credentials
kubectl apply -f externalsecret.yaml
This triggered an immediate sync. The new password appeared in the Kubernetes Secret within seconds.
How external-secrets sync actually works
External-secrets-operator runs a reconciliation loop. For each ExternalSecret:
- Check if it's time to sync (based on
refreshInterval) - If yes, query the secret from the provider (Azure Key Vault)
- Compare with the current Kubernetes Secret value
- If different, update the Kubernetes Secret
- Schedule next sync after
refreshInterval
The sync is not event-driven. External-secrets doesn't get notified when a secret changes in Azure. It just polls on a schedule.
So there's always a delay between updating a secret in Azure and having it available in Kubernetes. The delay is at most refreshInterval.
Why not use a shorter interval?
I could set refreshInterval: 1m to sync every minute. But that hits Azure Key Vault API 60 times per hour per ExternalSecret.
We have about 15 ExternalSecrets across different namespaces. That's 900 API calls per hour to Key Vault. Azure charges $0.03 per 10,000 transactions, so the cost is negligible. But it's unnecessary load for secrets that change maybe once a month.
Polling every hour is fine for normal use.
Forcing a sync manually
When you need a refresh right now, there are a few options.
Option 1: Delete and recreate the ExternalSecret
kubectl delete externalsecret name
kubectl apply -f externalsecret.yaml
Option 2: Annotate the ExternalSecret to trigger reconciliation
kubectl annotate externalsecret name force-sync="$(date +%s)" --overwrite
The operator watches for annotation changes and triggers a reconciliation.
Option 3: Restart the external-secrets controller pod
kubectl rollout restart deployment external-secrets -n external-secrets-system
This forces all ExternalSecrets to resync at once. Useful if you need to refresh many secrets.
Pods don't automatically reload secrets
Here's the other thing that tripped me up. Even after the Kubernetes Secret updates, pods don't see the new value. They loaded the secret at startup and keep using the old one.
To pick up the new secret, restart the pods:
kubectl rollout restart deployment app
This does a rolling restart. New pods get the new secrets, old pods get terminated after the new ones are healthy.
There's also Reloader, which watches Secrets and automatically restarts pods when values change:
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
annotations:
reloader.stakater.com/auto: "true" # auto-restart on secret change
spec:
template:
spec:
containers:
- name: app
envFrom:
- secretRef:
name: database-credentials
We don't use Reloader. I prefer knowing exactly when pods restart. Automatic restarts can cause problems if a secret is temporarily wrong or if the timing is bad.
A more controlled approach: versioned secrets
Instead of updating a secret in place, you can create a new version:
In Azure Key Vault:
postgresql-password-v1postgresql-password-v2
Then update the ExternalSecret to reference the new version:
data:
- secretKey: password
remoteRef:
key: postgresql-password-v2 # changed from v1
This triggers external-secrets to sync immediately (because the ExternalSecret resource itself changed) and creates a new Kubernetes Secret.
Then you update deployments to use it, test that it works, and clean up the old version.
More manual work, but you get more control over the rollout.
Debugging external-secrets
When secrets aren't syncing, here's what I check.
- Check ExternalSecret status:
kubectl describe externalsecret name
Look for errors or the last sync time.
- Check SecretStore connection:
kubectl describe secretstore azure-backend
Make sure the vault URL and managed identity are correct.
- Check external-secrets controller logs:
kubectl logs -n external-secrets-system deployment/external-secrets -f
Look for authentication errors or API failures.
- Test Azure Key Vault access manually:
az keyvault secret show --vault-name our-vault --name postgresql-password
If this fails, the managed identity probably doesn't have permission to read the secret.
Azure Key Vault permissions
The managed identity needs these permissions on the Key Vault:
getto read secret valueslistto list available secrets
Set via Access Policy:
az keyvault set-policy \
--name our-vault \
--object-id <managed-identity-object-id> \
--secret-permissions get list
Or using Azure RBAC (the newer way):
az role assignment create \
--role "Key Vault Secrets User" \
--assignee <managed-identity-client-id> \
--scope /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.KeyVault/vaults/our-vault
We use RBAC because it works better with Azure AD and is what Microsoft recommends now.
Why external-secrets instead of mounting secrets directly?
Kubernetes has a CSI driver that can mount Azure Key Vault secrets directly into pods. So why bother with external-secrets?
CSI driver approach:
- Secrets mounted as files in the pod filesystem
- Synced every 2 minutes (configurable)
- Requires the CSI driver installed
- Secrets are only available to pods, not to other Kubernetes resources
external-secrets approach:
- Secrets stored as Kubernetes Secret objects
- Available to anything in Kubernetes (ConfigMaps, ServiceAccounts, etc.)
- Works with GitOps (ExternalSecret defined in Git, actual value in Azure)
- Can combine multiple Key Vault secrets into one Kubernetes Secret
We went with external-secrets because it fits better with our GitOps setup and makes secrets available as native Kubernetes objects.
Lessons
refreshIntervalis the maximum delay, not how quickly secrets sync- Changing a secret in Azure doesn't immediately update pods
- You can force syncs by deleting/recreating the ExternalSecret or annotating it
- Pods need restarts to pick up new secret values
- For critical password rotations, versioned secrets give you more control
Once I understood the polling model, external-secrets made a lot more sense. The hourly default works for most cases. When you need an immediate update, forcing a sync takes seconds.