Wiring TLS to AKS via ExternalSecret and Azure Key Vault
Stored TLS certificates in Azure Key Vault and synced them into Kubernetes as a TLS secret using the External Secrets Operator, then wired them to the NGINX Ingress.
ON THIS PAGE
The <core-system>prod.<client>.co.ke) needed TLS. The certificate existed. The question was where to store it and how to get it into the cluster without manual kubectl create secret commands or committed cert files.
Azure Key Vault as source of truth, External Secrets Operator to sync, and an ExternalSecret manifest to define the mapping.
Environment
| Component | Detail |
|---|---|
| Cluster | AKS (<cluster>), West Europe |
| Ingress | NGINX Ingress Controller (nginx-prod class) |
| Secrets operator | External Secrets Operator (external-secrets-system namespace) |
| Key Vault | <keyvault> |
| Domain | <core-system>prod.<client>.co.ke |
Storing the certificate in Key Vault
The certificate and private key were uploaded as separate secrets:
az keyvault secret set \
--vault-name <keyvault> \
--name <core-system>-prod-tls-crt \
--file tls.crt
az keyvault secret set \
--vault-name <keyvault> \
--name <core-system>-prod-tls-key \
--file tls.key
Key names are intentionally verbose (<core-system>-prod-tls-crt) to avoid collisions with secrets from other applications sharing the same vault.
The ExternalSecret manifest
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: <core-system>-frontend-tls
namespace: <namespace>
spec:
refreshInterval: 1h
secretStoreRef:
name: azure-keyvault-store
kind: SecretStore
target:
name: <core-system>-frontend-tls
creationPolicy: Owner
template:
type: kubernetes.io/tls # creates a proper TLS secret type
data:
- secretKey: tls.crt
remoteRef:
key: <core-system>-prod-tls-crt
- secretKey: tls.key
remoteRef:
key: <core-system>-prod-tls-key
The template.type: kubernetes.io/tls field matters. Without it, the External Secrets Operator creates a generic Opaque secret. NGINX Ingress expects a kubernetes.io/tls typed secret — it validates the type before using it for TLS termination. With the wrong type, the ingress picks up the secret but silently falls back to the default self-signed certificate.
Wiring the ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: <core-system>-frontend-ingress
namespace: <namespace>
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: nginx-prod
tls:
- hosts:
- <core-system>prod.<client>.co.ke
secretName: <core-system>-frontend-tls
rules:
- host: <core-system>prod.<client>.co.ke
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: <core-system>-frontend
port:
number: 3000
The secretName in the tls block matches target.name in the ExternalSecret. The External Secrets Operator manages the lifecycle of the Kubernetes secret — creating it on first sync, updating it when the vault secret changes, and honouring the refreshInterval.
Verifying the sync
kubectl get externalsecret <core-system>-frontend-tls -n <namespace>
# NAME STORE REFRESH INTERVAL STATUS READY
# <core-system>-frontend-tls azure-keyvault-store 1h Valid True
kubectl get secret <core-system>-frontend-tls -n <namespace> -o yaml | grep type
# type: kubernetes.io/tls
Copying the secret to the dev namespace
The dev domain (<core-system>dev.<client>.co.ke) uses the same wildcard certificate. Rather than creating a separate ExternalSecret in <namespace-dev> pointing to the same vault secrets, I copied the already-synced Kubernetes secret across namespaces:
kubectl get secret <core-system>-frontend-tls -n <namespace> -o json \
| python3 -c "
import json, sys
s = json.load(sys.stdin)
s['metadata'] = {
'name': '<core-system>-frontend-tls',
'namespace': '<namespace-dev>'
}
s['metadata'].pop('resourceVersion', None)
print(json.dumps(s))
" \
| kubectl apply -f -
This works for a wildcard cert shared across envs, but the dev secret does not auto-renew when the vault secret changes. A separate ExternalSecret in <namespace-dev> is the cleaner long-term setup.
Certificate renewal
When the certificate expires and a new one is uploaded to Key Vault:
az keyvault secret set --vault-name <keyvault> --name <core-system>-prod-tls-crt --file new-tls.crt
az keyvault secret set --vault-name <keyvault> --name <core-system>-prod-tls-key --file new-tls.key
The ExternalSecret picks up the new values on the next refresh cycle (default: 1 hour). To force an immediate sync:
kubectl annotate externalsecret <core-system>-frontend-tls -n <namespace> \
force-sync="$(date +%s)" --overwrite
This triggers a reconciliation loop without waiting for the next interval.
What this avoids
The alternative — kubectl create secret tls — works but is manual, not reproducible, and puts certificate handling outside of git history. If the cluster is rebuilt or the namespace is recreated, the secret disappears and needs manual recreation.
With Key Vault as source of truth and ExternalSecret managing the sync, the certificate state is declarative: the ExternalSecret manifest defines what should exist, the operator ensures it does, and Key Vault holds the sensitive material outside the cluster.
Discussion