By the end you'll know
- Why a Kubernetes Secret can't go in Git as it is
- How Sealed Secrets works: a public key to lock, a private key in the cluster to unlock
- How to install a tool from someone else's Helm chart with an Argo CD Application
- How to seal a value with
kubeseal, and get it to a pod through the chart - Why a sealed value only works in the namespace it was sealed for
- Why the sealing key needs a backup, and where it must never go
Before you start
- You need: Lessons 1 to 9 done, with production on release 2 and the root Application managing everything.
- Install kubeseal:
brew install kubeseal, the command that encrypts values for the cluster. - Get the new files:
git pull. You'll haveargocd/sealed-secrets.yamland the chart'stemplates/sealedsecret.yaml. The encrypted password invalues-production.yamlwas sealed for my cluster, so yours can't read it: you'll seal your own in step 3. - Where commands run: your own computer's terminal, in
gitops-practice, logged in withargocd login.
The short version
- The problem. A Secret's value is only base64, which anyone can decode. A Secret in Git is a password in Git.
- The fix. Sealed Secrets: a controller in the cluster with a private key, and
kubeseal, which encrypts with the matching public key. Only the controller can decrypt. - The result. The encrypted value in Git, a real Secret in the cluster, the password in the pod, and the password itself nowhere in Git's history.
- The limits. A sealed value only unseals in the namespace and name it was sealed for. And if the cluster's private key is lost, nothing in Git can be read again, so it gets backed up, outside Git.
The one thing that can't go in Git
Everything the bookshop runs is now in Git: the chart, each environment's values, even the Applications. The next thing a real shop needs is a password, for a database, say. Kubernetes keeps those in a Secret:
kubectl create secret generic bookshop-db -n bookshop \
--from-literal=password=correct-horse-battery --dry-run=client -o yaml
apiVersion: v1
data:
password: Y29ycmVjdC1ob3JzZS1iYXR0ZXJ5
kind: Secret
metadata:
creationTimestamp: null
name: bookshop-db
namespace: bookshop
--dry-run=client -o yaml only prints it; nothing is created. The password looks scrambled, but it isn't:
echo Y29ycmVjdC1ob3JzZS1iYXR0ZXJ5 | base64 -d
correct-horse-battery
Base64 is encoding, not encryption. It turns any bytes into plain letters, so they survive YAML, and anyone can turn them back. Committing this file would put the password in Git for good: deleting it later leaves it in the history, on every clone, readable by everyone who can read the repository. GitOps needs a way to put the description of a secret in Git without the secret itself.
How Sealed Secrets works
Sealed Secrets splits the job between two pieces:
- A controller in the cluster, which makes a key pair when it first starts: a public key that anyone can have, and a private key that never leaves the cluster.
kubeseal, a command on your computer that fetches the public key and uses it to encrypt a value.
What you commit is a SealedSecret: a new kind of resource holding the encrypted value. Argo CD applies it like anything else. The controller sees it, decrypts it with the private key, and makes an ordinary Secret from it. Anything can lock; only the cluster can unlock.
Installing it with Argo CD
The controller comes as a Helm chart, published by the Sealed Secrets project. Helm charts can come from a chart repository, a web address that lists charts and their versions:
helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets
helm search repo sealed-secrets/sealed-secrets
NAME CHART VERSION APP VERSION DESCRIPTION
sealed-secrets/sealed-secrets 2.22.0 0.41.2 Helm chart for the sealed-secrets controller.
Two versions, as in Lesson 5: chart 2.22.0, which packages Sealed Secrets 0.41.2. To install it the GitOps way, one more file in argocd/, for the root to pick up:
# Sealed Secrets (Lesson 10): a controller that turns the encrypted SealedSecrets
# in this repository into ordinary Secrets in the cluster. From its own Helm chart.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: sealed-secrets
namespace: argocd
spec:
project: default
source:
repoURL: https://bitnami.github.io/sealed-secrets
chart: sealed-secrets
targetRevision: 2.22.0
helm:
releaseName: sealed-secrets
valuesObject:
# The name kubeseal looks for by default.
fullnameOverride: sealed-secrets-controller
destination:
server: https://kubernetes.default.svc
namespace: kube-system
syncPolicy:
automated:
prune: true
selfHeal: true
The source is different from the bookshop's. Instead of a Git path, it names a chart from a chart repository, and targetRevision is the chart version, pinned, so the controller only changes when this file does. valuesObject sets the chart's values right in the Application, here just a name. Merged, and a moment later:
deployment "sealed-secrets-controller" successfully rolled out
$ status sealed-secrets
Synced Healthy 2.22.0
$ kubectl get pods -n kube-system -l app.kubernetes.io/name=sealed-secrets
NAME READY STATUS RESTARTS AGE
sealed-secrets-controller-8b68c74c7-4ccq4 1/1 Running 0 6s
The revision Argo CD reports is 2.22.0, the chart version, where the bookshop's is a commit. A pull request, and a new tool in the cluster, installed the same way as the shop.
Sealing a password
kubeseal fetches the public key from the controller:
kubeseal --version
kubeseal --fetch-cert | head -3
kubeseal version: v0.31.0
-----BEGIN CERTIFICATE-----
MIIEzDCCArSgAwIBAgIQHG0U0QX7FZ1VnoX3qqx15jANBgkqhkiG9w0BAQsFADAA
MB4XDTI2MTEwNDIyMzc0MloXDTM2MTEwMTIyMzc0MlowADCCAiIwDQYJKoZIhvcN
A certificate, holding the public key: safe to share, safe to commit. Then a password, made just for this lesson, and sealed:
PW=$(openssl rand -hex 12)
ENC=$(printf %s "$PW" | kubeseal --raw --namespace bookshop --name bookshop-db)
echo "${ENC:0:64}... (${#ENC} characters)"
the password (a practice one, made just now): f994119e3a31e48126d04262
AgBm7k4wU43dPHni5hkTd96Q4MzYKo5qY7iMmDNcrZf7/Q86DH96342ahKr/lj2S... (740 characters)
--raw seals one value, rather than a whole Secret file. --namespace and --name say which Secret it's for, and they're sealed in with it: more on that below. A 24-character password became 740 characters. This one is base64 too, but of encrypted bytes. Decoding it gets you this:
00000000: 0200 66ee 4e30 538d dd3c 79e2 e619 1377 ..f.N0S..<y....w
00000010: de90 e0cc d82a 8e6a 63b8 8c98 335c ad97 .....*.jc...3\..
00000020: fbfd 0f3a 0c7f 7adf 8d9a 84aa ff96 3d92 ...:..z.......=.
Noise. Compare correct-horse-battery.
The chart, with a password
The chart gets one new template, templates/sealedsecret.yaml, which makes a SealedSecret only when an environment has an encrypted value:
{{- if .Values.db.encryptedPassword }}
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: {{ .Release.Name }}-db
labels:
{{- include "bookshop.labels" . | nindent 4 }}
spec:
encryptedData:
password: {{ .Values.db.encryptedPassword }}
template:
metadata:
name: {{ .Release.Name }}-db
{{- end }}
The Deployment hands the Secret to nginx as an environment variable, under the same condition:
{{- if .Values.db.encryptedPassword }}
env:
# From the Secret the Sealed Secrets controller makes (Lesson 10).
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-db
key: password
{{- end }}
nginx has no use for a database password, of course. It stands in for an app that would. The default in values.yaml is empty, and production's file gets the sealed value:
db:
- encryptedPassword: ""
+ encryptedPassword: "AgBm7k4wU43dPHni5hkTd96Q4MzYKo5qY7iMmDNcrZf7/Q86DH96342ahKr/lj2Sa6a
Staging has no value, so the check from Lesson 7 applies: does staging's render change?
helm template bookshop-staging charts/bookshop -f charts/bookshop/values.yaml \
-f charts/bookshop/values-staging.yaml | grep -c -e SealedSecret -e DB_PASSWORD
0
Nothing for staging. Merged, and production:
deployment "bookshop" successfully rolled out
Synced Healthy dd5a1f40802fc06
$ kubectl get sealedsecret,secret -n bookshop
NAME STATUS SYNCED AGE
sealedsecret.bitnami.com/bookshop-db True 6s
NAME TYPE DATA AGE
secret/bookshop-db Opaque 1 6s
$ kubectl get events -n bookshop --field-selector involvedObject.kind=SealedSecret
REASON MESSAGE
Unsealed SealedSecret unsealed successfully
Argo CD applied the SealedSecret; the controller unsealed it and made secret/bookshop-db from it, in the same few seconds. The new environment variable changed the pod template, so the pods rolled: new ReplicaSet 74d9f67bc8, as in every release.
What the cluster has, and what Git has
The cluster has the real password, in the Secret and in the pod:
kubectl get secret bookshop-db -n bookshop -o jsonpath='{.data.password}' | base64 -d; echo
kubectl exec -n bookshop deploy/bookshop -- printenv DB_PASSWORD
f994119e3a31e48126d04262
f994119e3a31e48126d04262
And Git? Search the values file, then every commit in the repository's history:
grep -c 'f994119e3a31e48126d04262' charts/bookshop/values-production.yaml
git log -p | grep -c 'f994119e3a31e48126d04262'
0
0
Zero, and zero. The password reached the pod without ever being written to Git. What's in Git is the 740 characters only this cluster can read.
Notice what that first command did, though. Anyone who can read Secrets in the bookshop namespace can read the password, exactly as easily as before. Sealed Secrets protects the password in Git. In the cluster, it's an ordinary Secret, and who can read it is up to Kubernetes permissions.
Sealed for one place
The sealed value said --namespace bookshop --name bookshop-db. What if someone copies it somewhere else? A throwaway namespace, scratch, and the same encrypted value in a SealedSecret there:
$ kubectl apply -f scratch-sealed.yaml
sealedsecret.bitnami.com/bookshop-db created
$ kubectl get secret bookshop-db -n scratch
Error from server (NotFound): secrets "bookshop-db" not found
$ kubectl get events -n scratch --field-selector involvedObject.kind=SealedSecret
REASON MESSAGE
ErrUnsealFailed Failed to unseal: no key could decrypt secret (password)
Refused. The namespace and name are part of what was encrypted, so the same value can't be unsealed anywhere else. That's the point: anyone allowed to create things in some other namespace can't copy production's sealed password there and read the result. It's also why staging, when it needs a password, gets its own, sealed with --namespace bookshop-staging. The scratch namespace was then deleted.
The key that unlocks everything
Everything sealed so far depends on one thing: the controller's private key.
kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key
NAME TYPE DATA AGE
sealed-secrets-keygzzt8 kubernetes.io/tls 2 33s
The key pair, as a Secret, made when the controller first started. If the cluster is lost, so is this, and every sealed value in Git becomes 740 characters nobody can read. A new controller makes a new key, which can't decrypt the old values. So the key gets a backup:
echo '.sealed-secrets-key.yaml' >> .gitignore
kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml > .sealed-secrets-key.yaml
chmod 600 .sealed-secrets-key.yaml
ls -l .sealed-secrets-key.yaml
-rw------- 1 mayowababatola staff 7085 Nov 4 17:38 .sealed-secrets-key.yaml
The backup goes in .gitignore before the file exists, so it can't be committed by accident, and only you can read it, like .argocd-admin from Lesson 2. The one file that must never go in Git is the one that unlocks everything that does. Lesson 11 needs it.
In real systems
- The backup lives somewhere safe, like a password manager or a cloud secrets store, not on one laptop. Here, a file is enough for practice.
- The controller makes new keys by default every 30 days, and keeps the old ones, so old values still unseal. A backup taken by label, as above, includes every key; take it again after a new one appears.
- Rotating a password means sealing a new value and merging it, like any change. Re-sealing doesn't change the password itself: if a password leaks, change it at its source.
- The other common way: keep secrets out of Git entirely, in a store such as AWS Secrets Manager or HashiCorp Vault, and have the External Secrets Operator copy them into the cluster. Git then holds only a reference. Sealed Secrets suits a small setup; a secrets store suits a large one with many teams and clusters. SOPS, which encrypts values inside ordinary files, is a third.
- Pin chart versions, and know where charts come from. Sealed Secrets moved its GitHub organization from
bitnami-labstobitnamiin 2026, and the old chart address now returns 404. An install pointing at it stops working, version pinned or not.
Spot the problem
Work out what's wrong before you read the explanation.
The version that wasn't there. This happened while making this lesson. A script looked up the chart's latest version with helm search repo, wrote it into argocd/sealed-secrets.yaml, and merged the file. The chart repository's address had moved, so helm repo add failed with a 404, the search found nothing, and the script carried on. What got merged said targetRevision: null. Then kubeseal failed, because there was no controller to fetch a key from, and the chart change after it merged with an empty password. The script merged both pull requests itself, so nobody looked. What went wrong? The script didn't stop on the first failure. A failed lookup became an empty value, and an empty value became a commit. It's still in this repository's history, as #34 and #35, followed by #37 and #38, which set the real version and value. The fix was two lines: check the version isn't empty before writing it, and stop if kubeseal returns nothing. Anything that writes to Git automatically should treat "I got nothing" as an error, not as a value.
Keep this
| To | Run |
|---|---|
| Read a Secret's value | kubectl get secret NAME -n NS -o jsonpath='{.data.KEY}' | base64 -d |
| Find a chart's versions | helm repo add NAME URL, then helm search repo NAME/CHART |
| Install a chart with Argo CD | An Application with repoURL, chart and a pinned targetRevision |
| Get the public key | kubeseal --fetch-cert |
| Seal one value | printf %s "VALUE" | kubeseal --raw --namespace NS --name NAME |
| Check it unsealed | kubectl get sealedsecret,secret -n NS |
| See why it didn't | kubectl get events -n NS --field-selector involvedObject.kind=SealedSecret |
| Check a password isn't in Git | git log -p | grep -c 'PASSWORD' |
| Back up the sealing key | kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml, to a file in .gitignore |
Where you are now
The bookshop has a password, and Git describes it without containing it. You've installed a tool from someone else's Helm chart the GitOps way, and you've got two files that must never go in Git: .argocd-admin and .sealed-secrets-key.yaml. Lesson 11 puts everything to the test: delete the whole cluster, and rebuild it from Git, with that key, as the only thing you bring.
Check yourself
Why can't a Kubernetes Secret go in Git as it is?
With Sealed Secrets, what can decrypt a sealed value?
In the sealed-secrets Application, what was targetRevision: 2.22.0?
The production sealed value was applied in a scratch namespace. Why did it fail?
Once unsealed, who can read the password?
Why does the sealing key backup go in .gitignore?
Key terms
- Secret: a Kubernetes resource for sensitive values, stored base64 encoded, not encrypted.
- Base64: a way of writing any bytes as plain letters. Anyone can decode it.
- Sealed Secrets: a controller and a command,
kubeseal, for keeping encrypted secrets in Git. - SealedSecret: the resource holding an encrypted value, which the controller turns into a Secret.
- Public key, private key: a pair. What one locks, only the other can unlock.
- kubeseal: the command that encrypts values with the cluster's public key.
- Chart repository: a web address listing Helm charts and their versions.
- External Secrets Operator: a controller that copies secrets from a secrets store into the cluster, instead of keeping them in Git.
Go deeper
- The Sealed Secrets README (opens in a new tab), especially scopes and key renewal, and the External Secrets Operator (opens in a new tab).
- Try: give staging its own password, sealed with
--namespace bookshop-staging --name bookshop-staging-db, invalues-staging.yaml. Which pods roll, and does production change? - Try: change the password by hand with
kubectl edit secret bookshop-db -n bookshop. Does the controller put it back? Does Argo CD notice?