18.8 Sealed Secrets: Encrypting Secrets for GitOps
Right, so you’ve got your Kubernetes cluster humming along, you’re deploying with GitOps, and you’ve hit the classic snag: you can’t just git commit your database password. Pushing a plain-text Secret to a git repo is like writing your PIN on a postcard. It’s a spectacularly bad idea. This is where Sealed Secrets come in. Think of them as a one-way encryption wrapper for your regular Kubernetes Secrets. You encrypt the secret on your local machine into a SealedSecret custom resource, you commit that to git, and the controller running in your cluster decrypts it, turning it back into a regular Secret. It’s magic, but the kind that runs on public-key cryptography.
The genius here is the public/private key split. The controller generates the private key, which never, ever leaves the cluster. It exposes the public key, which you use for encryption. This means you can encrypt secrets for the cluster without needing any access to the cluster. A developer can safely encrypt a production database password without ever having production access. Neat, huh?
Installing the Kubeseal CLI and Controller
First, you need the tooling. The kubeseal CLI is how you perform the encryption, and the controller is what does the decryption in-cluster.
# Grab the kubeseal CLI. Pick your poison, but Homebrew is easiest on a Mac.
brew install kubeseal
# Install the controller into your cluster. The following command fetches the latest YAML and applies it.
# This is the quick-start method; for production, you'd want to pin a specific version.
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.22.0/controller.yaml
Watch for the pod to become ready in the kube-system namespace (kubectl get pods -n kube-system -l name=sealed-secrets-controller). This controller is now the guardian of the private key.
Creating Your First SealedSecret
Let’s take a regular old Secret manifest and seal it. Here’s a generic example:
# secret-to-seal.yaml
apiVersion: v1
kind: Secret
metadata:
name: my-app-secret
namespace: my-namespace
type: Opaque
data:
password: c3VwZXJzZWNyZXRwYXNzd29yZCAg # base64 encoded "supersecretpassword"
Now, instead of kubectl apply -f secret-to-seal.yaml, we use kubeseal. The --scope flag is crucial—it locks the encrypted secret to a specific namespace, cluster-wide, or even a specific controller. strict (the default and my recommendation) locks it to its own namespace. This prevents someone from accidentally applying a secret encrypted for the dev namespace to production.
# Encrypt the secret and output the SealedSecret custom resource
kubeseal -f secret-to-seal.yaml --scope strict -o yaml > sealed-secret.yaml
Your resulting sealed-secret.yaml will look something like this. This is what you commit to git.
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: my-app-secret
namespace: my-namespace
spec:
encryptedData:
password: AgB... (a very long encrypted string)
template:
metadata:
name: my-app-secret
namespace: my-namespace
type: Opaque
When you apply this SealedSecret YAML (kubectl apply -f sealed-secret.yaml), the controller sees it, decrypts it using its private key, and creates a regular Kubernetes Secret named my-app-secret with the original data.
Key Management and Rotation
This is the part where most tools get fiddly, but Sealed Secrets handles it elegantly. The private key is itself stored in a Secret in the controller’s namespace. If it’s compromised, you have a problem. To rotate it, you need to generate a new key. The controller can actually manage multiple private keys, decrypting with the old ones and re-encrypting existing Sealed Secrets with the new one on the fly.
# To initiate a key rotation, you annotate the controller pod. It will generate a new key.
kubectl annotate secrets -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key=active key rotated="$(date +%s)"
The controller will now use the new key as the primary for encryption, but it can still decrypt secrets sealed with any of the older keys stored in that Secret. It’s a graceful process.
Common Pitfalls and The “Oh Crap” Moments
- Forgetting the –scope: If you seal a secret without a scope and apply it to a different namespace, it will fail to decrypt. The error message is pretty clear, but it’ll waste your time. Always be explicit.
- Editing a SealedSecret Manually: You can’t tweak the
encryptedDatafield by hand. It’s encrypted. If you need to change a value, you must go back to the original plain-text secret, modify it, and re-seal it withkubeseal. This is a feature, not a bug—it forces you to version control the source of truth. - Cluster-Scoped vs. Namespace-Scoped Controllers: By default, the controller is installed cluster-wide. If you install multiple controllers in different namespaces (not common), you need to tell
kubesealwhich one to use (--controller-namespaceand--controller-name). You’ll know you’ve messed this up when decryption mysteriously fails.
The beauty of Sealed Secrets is that it solves a very specific problem in a self-contained, Kubernetes-native way. It’s not a vault, it’s not a full-blown secrets management system. It’s a clever cryptographic envelope that lets you safely check your secrets into git, which is exactly what GitOps demanded.