Kubernetes Sealed Secrets: GitOps-Safe Secret Management (2026)
One of the fundamental challenges of GitOps is secrets management: you want everything in Git, but Kubernetes Secrets are only base64-encoded (not encrypted) and must never be committed to a repository. Bitnami Sealed Secrets solves this elegantly — it encrypts secrets with a cluster-specific public key so that only the Sealed Secrets controller running in your cluster can decrypt them. The resulting SealedSecret CRD is safe to commit to any Git repository, public or private.
Table of Contents
How Sealed Secrets Works
The Sealed Secrets system has two components: a controller (cluster-side) and the kubeseal CLI (developer-side).
- The controller generates an asymmetric key pair (RSA-OAEP, 4096-bit by default) when first installed and stores the private key in a Kubernetes Secret in the
kube-systemnamespace. - The controller exposes its public key at
/v1/cert.pemon the controller Service. - Developers run
kubeseal, which fetches the public key and encrypts the secret data locally. The result is aSealedSecretmanifest. - The
SealedSecretis committed to Git and applied to the cluster (by ArgoCD, Flux, or manually). - The controller watches for
SealedSecretresources, decrypts them using its private key, and creates the corresponding KubernetesSecretin the same namespace.
Only the controller can decrypt the sealed data — not developers, not CI/CD pipelines, and not anyone with read access to the Git repository. The encryption is scoped so that a SealedSecret created for namespace=production cannot be decrypted if moved to namespace=staging.
DATABASE_PASSWORD) are visible in the SealedSecret manifest, but the values are opaque ciphertext. This is acceptable for most teams — if the key names themselves are sensitive, use an alternative like External Secrets Operator with a dedicated secrets vault.
Installing the Controller and kubeseal
Install the Sealed Secrets controller via Helm, then download the matching kubeseal CLI binary.
# Add the Bitnami Helm repository
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm repo update
# Install the controller in kube-system
helm upgrade --install sealed-secrets sealed-secrets/sealed-secrets \
--namespace kube-system \
--set fullnameOverride=sealed-secrets-controller
# Verify the controller is running
kubectl get pods -n kube-system -l app.kubernetes.io/name=sealed-secrets
# Install kubeseal CLI (Linux/macOS)
KUBESEAL_VERSION=$(curl -s https://api.github.com/repos/bitnami-labs/sealed-secrets/releases/latest \
| grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
curl -OL "https://github.com/bitnami-labs/sealed-secrets/releases/download/${KUBESEAL_VERSION}/kubeseal-${KUBESEAL_VERSION#v}-linux-amd64.tar.gz"
tar -xvzf kubeseal-*.tar.gz kubeseal
sudo install -m 755 kubeseal /usr/local/bin/kubeseal
# Verify
kubeseal --version
# Fetch and cache the cluster's public key (optional but useful for offline use)
kubeseal --fetch-cert \
--controller-name=sealed-secrets-controller \
--controller-namespace=kube-system \
> pub-sealed-secrets.pem
Creating Your First SealedSecret
The typical workflow is: create a plain Kubernetes Secret manifest, pipe it through kubeseal to produce an encrypted SealedSecret, then commit the SealedSecret to Git and delete the plaintext Secret.
# Create a plain Secret (do NOT commit this to Git)
kubectl create secret generic db-credentials \
--namespace=production \
--from-literal=username=admin \
--from-literal=password='S3cr3tP@ssw0rd!' \
--dry-run=client \
-o yaml > db-credentials-plain.yaml
# Seal it using the cluster public key
kubeseal \
--controller-name=sealed-secrets-controller \
--controller-namespace=kube-system \
--format yaml \
< db-credentials-plain.yaml \
> db-credentials-sealed.yaml
# View the encrypted output (safe to commit)
cat db-credentials-sealed.yaml
# Commit to Git and apply to cluster
git add db-credentials-sealed.yaml
git commit -m "Add sealed database credentials for production"
kubectl apply -f db-credentials-sealed.yaml
# Verify the controller decrypted and created the Secret
kubectl get secret db-credentials -n production
The resulting SealedSecret manifest looks like this:
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: db-credentials
namespace: production
spec:
encryptedData:
password: AgBy8hCe3nOW... # Opaque ciphertext
username: AgCH7Jp8nZ3p... # Opaque ciphertext
template:
metadata:
name: db-credentials
namespace: production
type: Opaque
db-credentials-plain.yaml to Git. Add *-plain.yaml and *-secret.yaml patterns to your .gitignore. Only the sealed (-sealed.yaml) files should be in version control.
Secret Scopes and Namespaces
Sealed Secrets supports three scoping modes that control where a SealedSecret can be decrypted:
- strict (default) — the SealedSecret can only be decrypted in the same namespace with the same name. Safest option.
- namespace-wide — can be decrypted with any name in the same namespace. Useful for chart templates that generate secret names dynamically.
- cluster-wide — can be decrypted anywhere in the cluster under any name. Use only for cluster-level secrets that truly need to be namespace-agnostic.
# Create a namespace-wide scoped secret
kubeseal --scope namespace-wide \
--controller-name=sealed-secrets-controller \
--controller-namespace=kube-system \
--format yaml \
< my-secret.yaml > my-secret-sealed.yaml
# Create a cluster-wide scoped secret
kubeseal --scope cluster-wide \
--controller-name=sealed-secrets-controller \
--controller-namespace=kube-system \
--format yaml \
< my-secret.yaml > my-secret-sealed.yaml
ArgoCD GitOps Integration
Sealed Secrets integrates seamlessly with ArgoCD. The SealedSecret CRD manifests live in Git alongside your other Kubernetes manifests. ArgoCD syncs them to the cluster, and the Sealed Secrets controller automatically decrypts and creates the corresponding Kubernetes Secrets.
# ArgoCD Application pointing to a repo with SealedSecrets
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: payment-service
namespace: argocd
spec:
project: production
source:
repoURL: https://github.com/myorg/k8s-manifests
targetRevision: HEAD
path: apps/payment-service
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
In your Git repository structure, SealedSecrets live alongside Deployments and other manifests:
apps/payment-service/
├── deployment.yaml
├── service.yaml
├── ingress.yaml
├── db-credentials-sealed.yaml ← Safe to commit
├── api-keys-sealed.yaml ← Safe to commit
└── kustomization.yaml
ArgoCD will show SealedSecrets as managed resources. The resulting decrypted Kubernetes Secrets are shown as child resources. If a SealedSecret is deleted from Git, ArgoCD's prune policy will delete both the SealedSecret and the resulting Secret.
Key Rotation and Backup
The Sealed Secrets controller generates a new key pair every 30 days by default. Old keys are kept so existing SealedSecrets can still be decrypted. New SealedSecrets are encrypted with the latest key.
# List all sealing keys
kubectl get secrets -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key
# Back up all sealing keys (CRITICAL — store securely offline)
kubectl get secret -n kube-system \
-l sealedsecrets.bitnami.com/sealed-secrets-key \
-o yaml > sealed-secrets-keys-backup.yaml
# Restore keys on a new cluster (before applying any SealedSecrets)
kubectl apply -f sealed-secrets-keys-backup.yaml
kubectl rollout restart deployment sealed-secrets-controller -n kube-system
# Force immediate key rotation (e.g., after a suspected compromise)
kubectl annotate secret \
-n kube-system \
-l sealedsecrets.bitnami.com/sealed-secrets-key \
sealedsecrets.bitnami.com/sealed-secrets-key=compromised
# The controller will generate a new key on next reconciliation
# Re-seal all secrets with the new key:
for f in $(find . -name '*-sealed.yaml'); do
kubeseal --re-encrypt < $f > $f.new && mv $f.new $f
done
Multi-Cluster Workflows
Each Sealed Secrets controller generates its own unique key pair, so a SealedSecret created for cluster A cannot be decrypted by cluster B. For multi-cluster setups you have two options:
Option 1: Seal per-cluster. Maintain a public key file for each cluster and seal secrets separately for each target:
# Fetch public keys for each cluster
kubeseal --fetch-cert --context cluster-staging > pub-staging.pem
kubeseal --fetch-cert --context cluster-production > pub-production.pem
# Seal for staging
kubeseal --cert pub-staging.pem --format yaml < secret.yaml > secret-staging-sealed.yaml
# Seal for production
kubeseal --cert pub-production.pem --format yaml < secret.yaml > secret-production-sealed.yaml
Option 2: Share the sealing key. Copy the private key from one cluster to another so they share the same key pair. This simplifies operations but means a single key compromise affects all clusters:
# Export the primary cluster's sealing key
kubectl get secret sealed-secrets-key -n kube-system \
--context=cluster-primary -o yaml > primary-key.yaml
# Import to secondary cluster
kubectl apply -f primary-key.yaml --context=cluster-secondary
kubectl rollout restart deployment sealed-secrets-controller \
-n kube-system --context=cluster-secondary
Frequently Asked Questions
How does Sealed Secrets compare to External Secrets Operator?
Sealed Secrets stores encrypted secrets directly in Git — simple, self-contained, no external dependencies. External Secrets Operator (ESO) integrates with external vaults (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) and syncs secrets into Kubernetes at runtime. ESO is better for organizations already using a secrets vault; Sealed Secrets is better for teams wanting a lightweight, Git-native solution with no external service dependency.
Can I update a SealedSecret without re-sealing all fields?
Yes. You can merge encrypted values. If you need to add one new field to an existing SealedSecret, seal just that field as a separate secret, then merge the encryptedData sections manually in the YAML. This avoids having to hold plaintext for all fields just to add one new one.
What happens if someone gets the SealedSecret YAML from Git?
They get opaque RSA-OAEP ciphertext. Without the controller's private key, it is computationally infeasible to recover the plaintext. The security of the system depends on protecting the private key in the cluster (and your backups), not on the secrecy of the Git repository.