The problem
In GitOps everything the cluster runs comes from git - including the database password. But a Kubernetes Secret manifest (15.33) only base64-encodes its values, and encoding is not encryption:
$ echo 'U3VtbWVyMjAyNiE=' | base64 -d
Summer2026!
(base64 -d decodes; anyone can run it.) Commit that to a config repo and every clone, fork, CI cache and backup has the password forever - rewriting git history does not reach the copies. You still want the existence of the secret, its name and its keys in git; only the value must not be readable there. There are two families of answers.
What you need to know already: 15.33 and 17.41 (Secrets, base64), 26.2 (overlays, patches), 26.4 (Argo CD applies what is in git), 22.23 (Key Vault), 22.12 (workload identity: a pod's ServiceAccount signs in to Azure without a password), 25.24 (Helm's checksum annotation).
Sealed Secrets: encrypt it for the cluster
Sealed Secrets is a controller (sealed-secrets-controller in kube-system) plus a command, kubeseal. It uses asymmetric encryption: a key pair where what the public key encrypts only the matching private key can decrypt. The controller keeps the private key; anyone can have the public one. So anyone can seal, and only that controller can unseal:
kubectl create secret generic payments-db -n pay-dev \
--from-literal=DB_PASSWORD="$(cat pw.txt)" --dry-run=client -o yaml \
| kubeseal --format yaml > overlays/dev/payments-db-sealed.yaml
Piece by piece:
kubectl create secret generic payments-db -n pay-dev- build a Secret namedpayments-dbinpay-dev...--from-literal=DB_PASSWORD="$(cat pw.txt)"- ...with one key, its value read from a file (so it is not typed on the command line)...--dry-run=client -o yaml- ...but do NOT send it to the cluster: just print it as YAML.| kubeseal --format yaml- encrypt it with the controller's public key and print a SealedSecret instead.> overlays/dev/payments-db-sealed.yaml- save that into the repo.
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: payments-db
namespace: pay-dev
spec:
encryptedData:
DB_PASSWORD: AgBy3i4OJSWK+PiTySYZZA1rO43cGDEq... # ciphertext: useless without the private key
template:
metadata: {name: payments-db, namespace: pay-dev} # what the resulting Secret looks like
The SealedSecret goes into git; Argo CD applies it; the controller decrypts it into a normal Secret it owns. kubeseal --fetch-cert > pub.pem saves the public key (as a certificate) so people and CI can seal offline with --cert pub.pem.
Sealing scope
Scope - where the ciphertext may be opened - is baked into the encryption:
strict (default) bound to namespace AND name: copy it elsewhere and it does not open
namespace-wide any name, same namespace (kubeseal --scope namespace-wide)
cluster-wide anywhere (kubeseal --scope cluster-wide)
A strict SealedSecret copied to another namespace fails with no key could decrypt secret (DB_PASSWORD) - on purpose: otherwise anyone who can create a SealedSecret in their own namespace could copy prod's there and read it.
Operational facts:
- The controller generates a new key pair every 30 days and keeps the old ones, so old SealedSecrets still open.
- Back up the keys (
kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml). Lose the cluster without them and every SealedSecret in git is garbage. - Rotating the value means sealing a new value and committing it; rotating the key does not re-encrypt what is already in git.
External Secrets Operator: keep the value outside
External Secrets Operator (ESO) leaves the value in a real secret manager (Azure Key Vault from 22.23, AWS Secrets Manager, HashiCorp Vault...) and copies it into a Kubernetes Secret. An operator is a controller that manages some outside system or app for you. Git only holds a pointer:
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore # platform-owned: HOW to reach the vault
metadata: {name: azure-kv}
spec:
provider:
azurekv:
vaultUrl: https://kv-payments-lab.vault.azure.net/
authType: WorkloadIdentity # ESO signs in to Azure as its ServiceAccount (22.12)
serviceAccountRef: {name: external-secrets, namespace: external-secrets}
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret # team-owned: WHAT to fetch, into which Secret
metadata: {name: payments-api-key, namespace: pay-dev}
spec:
refreshInterval: 1m # re-read the vault every minute
secretStoreRef: {kind: ClusterSecretStore, name: azure-kv}
target: {name: payments-api-key} # the Kubernetes Secret to create
data:
- secretKey: API_KEY # key in the Kubernetes Secret
remoteRef: {key: payments-api-key} # name of the secret in Key Vault
- A SecretStore / ClusterSecretStore says how to reach a vault (one namespace / the whole cluster).
- An ExternalSecret says which vault entries to copy into which Secret.
Rotation happens in Key Vault (az keyvault secret set ..., 22.23) and reaches the cluster within refreshInterval. Access control is the vault's (RBAC on the vault, workload identity for the operator - chapter 22), the audit trail is the vault's logs, and a leaked git repo contains no secrets at all.
Neither restarts your pods
Both write a Secret. Environment variables from a Secret are read when the container starts, so a rotated value reaches running pods only on restart. (Files from a mounted Secret volume update in place after a minute or so - if the app re-reads them.) Options:
- a restart after rotation (
kubectl rollout restart); - a controller like Stakater Reloader, which restarts Deployments annotated with the Secret's name when it changes;
- a checksum annotation (25.24) only works for Secrets the chart itself renders, not these.
Others you will meet
- SOPS (with age keys or Azure Key Vault keys): encrypts just the values inside normal YAML in git, so files stay readable and diffable; Argo CD decrypts them via a plugin, Flux natively. The decryption key must reach the GitOps controller. (
ageis a small file-encryption tool.) - Secrets Store CSI driver (22.27): mounts vault secrets as files directly into pods, optionally also creating a Secret. No Secret object needed at all.
Choosing
Sealed Secrets simple, no external dependency, value lives in git (encrypted);
rotation = re-seal + commit; the sealing key is a crown jewel
ESO + Key Vault the value never touches git, central rotation and audit, needs the
vault and identity plumbing; the standard in Azure-heavy banks
What you can now do
- Explain why a Secret manifest must not be committed, and what "scope" means for a SealedSecret.
- Seal a value with
kubectl create secret --dry-run=client -o yaml | kubeseal. - Point an ExternalSecret at a Key Vault entry, and know that pods still need a restart.