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:

  1. Restore the key pair — export the key from the old cluster before destroying it, import into the new one
  2. 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:

  1. Fetch the new cluster's public cert
  2. Iterate through all SealedSecret manifests
  3. Reconstruct the plaintext from Vault
  4. Re-seal with the new cert
  5. 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.