Sealed Secrets: the full ceremony
Published: 2026-02-11
SealedSecrets lets you commit encrypted secrets to Git. Only the controller in the cluster can decrypt them. This post covers the full workflow: initial install, offline sealing, cert backup, what happens when you migrate clusters, and the operational gotchas that trip up teams after the initial setup.
How it works
The controller generates an RSA key pair on first start. The public key is available to anyone who can reach the cluster (or who has a copy of the cert). The private key never leaves the cluster.
You encrypt a Kubernetes Secret manifest with the public key → get a SealedSecret manifest → commit it → the controller decrypts it and creates the real Secret.
The encryption is namespace- and name-scoped by default. A SealedSecret created for my-secret in namespace app cannot be decrypted if you rename the secret or move it to a different namespace. This prevents replay attacks — stealing a SealedSecret from one cluster and deploying it to another doesn't work without the private key.
Scope modes
Three scoping modes available via --scope flag:
| Scope | Bound to |
|---|---|
strict (default) |
namespace + name |
namespace-wide |
namespace only |
cluster-wide |
no binding |
cluster-wide is useful for shared secrets (container registry credentials) that need to work across multiple namespaces without re-sealing. Use with care — a cluster-wide SealedSecret can be decrypted by any namespace.
Install
We install the controller via Helm as part of the bootstrap playbook, before FluxCD runs:
bashhelm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm upgrade --install sealed-secrets sealed-secrets/sealed-secrets \
--namespace kube-system \
--set fullnameOverride=sealed-secrets-controller \
--wait --timeout 120s
fullnameOverride=sealed-secrets-controller is important: kubeseal defaults to looking for a controller named sealed-secrets-controller. If your release is named differently (e.g., the Helm release name sealed-secrets results in sealed-secrets-sealed-secrets), you'll see confusing "controller not found" errors when running kubeseal without explicit --controller-name.
After install, verify the controller is running and has generated a key:
bashkubectl get pods -n kube-system | grep sealed-secrets
kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key
The label selector shows the active signing key. If this is empty, the controller hasn't generated a key yet — wait a few seconds and retry.
Fetch and save the public cert
After the controller is running, fetch the public cert and save it locally:
bashkubeseal --fetch-cert \
--controller-name=sealed-secrets-controller \
--controller-namespace=kube-system \
> ~/.kube/sealed-secrets-infra.pem
Store this in a password manager or secure S3 bucket. You'll need it for offline sealing on machines that don't have kubeconfig access to the cluster.
The cert is not sensitive — it's a public key. It's safe to commit to the repo in a .kubeconfigs/ directory alongside the sealed secrets it's used with. This makes CI sealing straightforward without any special secret handling for the cert itself.
Cert rotation
By default, the controller generates a new key every 30 days but keeps old keys active for decryption. Secrets sealed with old keys continue to work — the controller tries each key until one succeeds.
To force a rotation:
bashkubectl annotate secret \
-n kube-system \
-l sealedsecrets.bitnami.com/sealed-secrets-key \
sealedsecrets.bitnami.com/rotate-key=force
After rotation, update your local .pem file by re-fetching the cert. Old SealedSecrets still decrypt fine; new secrets will be sealed with the new key.
Online sealing (simplest)
If you have cluster access:
bash# Create the plain secret manifest
kubectl create secret generic my-secret \
--namespace=app \
--from-literal=api-key=supersecret \
--dry-run=client -o yaml \
| kubeseal \
--controller-name=sealed-secrets-controller \
--controller-namespace=kube-system \
--format yaml \
> my-sealed-secret.yaml
kubeseal calls the cluster to get the cert automatically. No need for a pre-saved .pem.
Offline sealing (from CI or air-gapped)
Use the saved cert:
bashkubectl create secret generic my-secret \
--namespace=app \
--from-literal=api-key=supersecret \
--dry-run=client -o yaml \
| kubeseal \
--cert ~/.kube/sealed-secrets-infra.pem \
--format yaml \
> my-sealed-secret.yaml
This is what our GitLab CI does when sealing secrets during deployment pipelines — the cert is stored as a GitLab CI file variable, no kubeconfig needed.
Multi-cluster sealing from CI
If you have multiple clusters (infra, dev, test), each with its own Sealed Secrets controller, you need separate .pem files:
bash# Seal for infra
kubeseal --cert .kubeconfigs/sealed-secrets-infra.pem ...
# Seal for dev
kubeseal --cert .kubeconfigs/sealed-secrets-dev.pem ...
The resulting SealedSecret manifests are different even if the plaintext is the same — each is encrypted to a different public key.
Sealing kubeconfigs for hub-and-spoke
Kubeconfigs are just Secrets with a file key. We seal them the same way:
bashkubectl create secret generic dev-kubeconfig \
--namespace=flux-system \
--from-file=value=.kubeconfigs/dev.yaml \
--dry-run=client -o yaml \
| kubeseal \
--cert ~/.kube/sealed-secrets-infra.pem \
--format yaml \
> fluxcd/dev-kubeconfig-sealed.yaml
The controller on infra decrypts it, Flux uses it to talk to dev. Note the namespace is flux-system — this must match where the Kustomization that references the secret is running.
Sealing Docker registry credentials
Image pull secrets in JSON format:
bashkubectl create secret docker-registry registry-creds \
--namespace=app \
--docker-server=registry.example.com \
--docker-username=myuser \
--docker-password=mypass \
--dry-run=client -o yaml \
| kubeseal \
--cert ~/.kube/sealed-secrets-infra.pem \
--format yaml \
> registry-creds-sealed.yaml
For cluster-wide registry credentials (so every pod can pull without specifying imagePullSecrets in each namespace):
bashkubectl create secret docker-registry registry-creds \
--namespace=kube-system \
--docker-server=registry.example.com \
--docker-username=myuser \
--docker-password=mypass \
--dry-run=client -o yaml \
| kubeseal \
--cert ~/.kube/sealed-secrets-infra.pem \
--scope cluster-wide \
--format yaml \
> registry-creds-sealed.yaml
You cannot partially update a SealedSecret
This is the biggest gotcha: there is no "update field X" operation. If you need to change any value — even one field — you must re-seal the entire Secret with the new values and replace the whole SealedSecret manifest.
Workflow:
bash# Get current values (if you have them stored separately)
kubectl get secret my-secret -n app -o yaml | kubectl neat > /tmp/my-secret.yaml
# OR reconstruct from scratch with the new values
# Edit the values in /tmp/my-secret.yaml
# Re-seal the whole thing
kubeseal --cert ~/.kube/sealed-secrets-infra.pem --format yaml \
< /tmp/my-secret.yaml > my-sealed-secret.yaml
git add my-sealed-secret.yaml && git commit -m "rotate my-secret" && git push
This means you need to keep the plaintext values stored somewhere (Vault, password manager) if you ever want to update them without having cluster access.
Cluster migration: re-sealing everything
When you rebuild a cluster from scratch, the controller generates a new key pair. All existing SealedSecrets are useless — the new controller can't decrypt them.
Options:
- Restore the key pair — export the key from the old cluster before destroying it, import into the new one
- Re-seal all secrets — tedious but clean if you have the plaintext in Vault
Option 1: Restore the key pair
Export the old key before destroying the cluster:
bashkubectl get secret \
-n kube-system \
-l sealedsecrets.bitnami.com/sealed-secrets-key=active \
-o yaml > sealed-secrets-key-backup.yaml
Store this in the password manager. It's the most sensitive file in your infrastructure — whoever has this file can decrypt every SealedSecret in your repo.
Import into the new cluster before Flux reconciles:
bashkubectl apply -f sealed-secrets-key-backup.yaml
kubectl rollout restart deployment sealed-secrets-controller -n kube-system
The controller picks up the restored key and can decrypt all existing SealedSecrets.
Option 2: Re-seal all secrets
If you have all plaintext values in Vault (which you should), re-sealing is straightforward:
- Fetch the new cluster's public cert
- Iterate through all SealedSecret manifests
- Reconstruct the plaintext from Vault
- Re-seal with the new cert
- Commit and push
This is more work but results in a cleaner state — no dependency on preserving old key material.
Checking decryption status
After deploying a SealedSecret:
bash# Check if the SealedSecret synced to a real Secret
kubectl get sealedsecret my-secret -n app
kubectl get secret my-secret -n app
# If the Secret doesn't exist, check events
kubectl describe sealedsecret my-secret -n app
Common error messages:
| Error | Cause |
|---|---|
no key could decrypt secret |
Wrong cluster — sealed for a different controller |
illegal base64 data |
Cert or secret got corrupted during copy |
namespace/name mismatch |
Secret was moved or renamed after sealing |
If you see no key could decrypt secret, check that the SealedSecret was sealed with the correct cluster's public cert. This happens frequently when you seal for infra but accidentally commit to the dev path.