Kubernetes Secrets are base64-encoded, not encrypted. We learned that the expensive way: a Secret manifest with a real PostgreSQL password sat in git for months because "it's base64, it's fine." It is not fine. base64 is encoding, not a security control. Here's the leak and the setup that makes it impossible to repeat.
Symptom
secret.yaml with our DB password, base64-encoded, committed to the repo. A new hire on day two ran the reversal out of curiosity:
$ echo 'czNjcjN0' | base64 -d
s3cr3t
Production-adjacent DB password, plaintext, one command, from a value that had been in git history for months. Nobody had done anything malicious. The value was simply readable by every person and CI job that had ever cloned the repo, and by anyone who found the repo later. A leak is not an event, it's a state — and we'd been sitting in it silently the entire time.
Root cause
Straight from the Kubernetes Secrets docs: Secrets are stored unencrypted by default in the API server's data store (etcd) — just base64-encoded. Their contents are readable by anyone with API read access, etcd access, or the ability to create Pods in that namespace. "It's a Secret object" does not mean "it's encrypted."
Worse: deleting the file didn't help. The value stayed in git history. Deleting a committed secret does not un-leak it — we treated the password as fully compromised and rotated it. There's a solid first-principles breakdown of separating config from code at this configuration & secrets writeup.
The fix
1. Right object for the right data
We'd dumped everything — log level, DB host, and the password — into one place. The split:
- ConfigMap for non-sensitive settings (log level, DB host/port/name). No secrecy, 1 MiB limit, same namespace as the Pod.
- Secret for passwords, keys, tokens.
Use stringData so you never hand-encode base64:
# secret.yaml (NEVER committed with real values)
apiVersion: v1
kind: Secret
metadata:
name: myapp-db-secret
namespace: myapp
type: Opaque
stringData:
DB_USER: "myapp"
DB_PASSWORD: "s3cr3t"
2. Templates + .gitignore
The cheapest fix, the one we should've had on day one: commit a template with placeholders, keep the real file out of git.
# .gitignore
secret.yaml
*.secret.yaml
.env
# secret.example.yaml -- safe to commit
stringData:
DB_USER: "CHANGE_ME"
DB_PASSWORD: "CHANGE_ME"
Clone, copy secret.example.yaml to secret.yaml, fill in real values, git never sees them. That's the whole thing we were missing.
3. Sealed Secrets for GitOps
We did want desired cluster state in git for GitOps — which means encrypting the secret before committing, real encryption this time. Bitnami's Sealed Secrets: a controller with a key pair lives in the cluster, kubeseal encrypts your Secret with the public key into a SealedSecret, and only the controller can decrypt it, inside the cluster:
kubectl -n myapp create secret generic myapp-db-secret \
--from-literal=DB_PASSWORD='s3cr3t' \
--dry-run=client -o yaml \
| kubeseal -o yaml > sealed-db-secret.yaml
# safe to commit, even to a public repo
Two things on the wall: the encryption is tied to the namespace + name pair, so a SealedSecret won't decrypt under a different one; and you must back up the controller's private key, or every committed SealedSecret becomes unreadable garbage.
4. dev/prod values without duplicate manifests
Local uses a stub password and LOG_LEVEL=debug; prod a real password and LOG_LEVEL=info. Copying manifests into two folders is how they drift. Kustomize's configMapGenerator has a content-based hash suffix in the generated name. Change the config, the name changes (myapp-config-7c8f...), Kustomize updates the Deployment reference, Kubernetes does a rolling update on its own:
kubectl apply -k overlays/dev
That also fixed a subtler bug: env vars are fixed at Pod startup and don't update when you change a ConfigMap. We'd edited a ConfigMap and stared at a Pod running old values. The hash suffix solves it automatically; without it you need kubectl rollout restart deployment/myapp.
Feeding values into a Pod — and the reload trap
Creating the objects is half the job; you still deliver the values, three ways, each with different reload behavior:
-
env+valueFrom— one key under a specific variable name. Precise, verbose. -
envFrom— every key at once, optionally with aprefix. - volume mount — each key becomes a file (TLS certs, runtime config).
The trap, stated plainly in the ConfigMap docs: env vars are fixed at Pod startup and never update when the underlying ConfigMap/Secret changes — the Pod runs old values until kubectl rollout restart. A volume mount updates automatically (small kubelet sync delay), but only mounted without subPath, and the app still has to re-read the file. We lost an hour editing a ConfigMap and expecting the running Pod to notice. It never did.
The rotation runbook we actually ran
Once we understood the leak, the response was mechanical. Order matters — rotate before you rewrite history, because the old value is already out.
- Treat the credential as compromised. Rotate it at the source first:
# new password in Postgres, then update the (uncommitted) Secret
psql -c "ALTER USER myapp WITH PASSWORD 's0m3-new-value';"
kubectl -n myapp create secret generic myapp-db-secret \
--from-literal=DB_PASSWORD='s0m3-new-value' \
--dry-run=client -o yaml | kubectl apply -f -
kubectl rollout restart deployment/myapp -n myapp
- Confirm where else the old value leaked:
git log -p -- secret.yaml | grep -i password # every historical value, in the open
- Purge it from history (
git filter-repo), force-push, and rotate anything that shared it. The purge is cosmetic — the rotation in step 1 is the control that actually protects you.
Before / after
| Before | After | |
|---|---|---|
| DB password | base64 in git history | rotated; never committed |
| Config vs secrets | all mixed together | ConfigMap + Secret split |
| Local secrets | committed | templates + .gitignore
|
| GitOps secrets | impossible / unsafe | Sealed Secrets (encrypted) |
| dev/prod values | copy-pasted manifests | Kustomize overlays + generators |
Guardrail
- base64 is not protection. A Secret with a real value in git equals a leaked value.
- Deleting the file doesn't help — git history keeps it; rotate.
-
envdoesn't hot-reload a ConfigMap/Secret change; a volume mount does (withoutsubPath), but the app still re-reads the file. -
A missing referenced ConfigMap/Secret stops the Pod from starting unless you mark the source
optional: true. A typo in a name became a Pod that never scheduled, with a confusing error, until we learned to read it. - Turn on encryption at rest and lock down RBAC in any cluster that holds a real secret. The docs' own remedy is exactly that.
What I'd do differently
Put the .gitignore in the repo template on day zero, and run a pre-commit secret scanner so no secret.yaml with real values can ever be staged. For local k3d, templates plus .gitignore are genuinely enough — Sealed Secrets are overkill there — but the same repo flows to CI and prod, so the direction has to be right from the start.
Bottom line: a Kubernetes Secret in git is a leaked secret, because base64 is encoding not encryption — commit templates, seal anything that must live in git, and rotate the moment a real value touches a commit.
Sources
- Kubernetes docs — Secrets: why base64 is not encryption
- Kubernetes docs — Encrypting Confidential Data at Rest
- Kubernetes docs — ConfigMaps: env vs mounted-volume reload
- bitnami-labs/sealed-secrets — Encrypt a Secret into a SealedSecret, safe for git
- Kustomize reference — configMapGenerator and the content-hash suffix
- Local-Kubernetes configuration & secrets writeup
Top comments (0)