Skip to content

Sealed Secrets on Kestrel

GitOps wants everything that defines your workload to live in Git — but a Kubernetes Secret is just base64-encoded plaintext, so committing one is the same as committing the raw password. You need the secret in your repo so ArgoCD can deploy it, but you must never commit it in the clear.

Sealed Secrets solves this with asymmetric encryption. The kubeseal CLI encrypts your Secret against the cluster’s public certificate and emits a SealedSecret — a custom resource that is safe to commit to Git, because only the private key can decrypt it. That private key lives inside a controller RCS runs in the sealed-secrets namespace and never leaves it. When the SealedSecret is applied to the cluster — by ArgoCD, or with kubectl apply — the controller decrypts it into a normal Secret of the same name in the same namespace, where your workloads consume it as usual.

So your repo holds the ciphertext, the cluster holds the key, and the plaintext only ever exists in memory inside the controller. The core benefit is that your secrets become safe to store in Git. From there, the recommended path on Kestrel is to let ArgoCD deploy them from your GitOps repo — but that is a recommendation, not a requirement. You can equally apply a SealedSecret yourself with kubectl.

Sealed Secrets has two halves. RCS runs the cluster-side half; you run the client-side half. You never touch the controller or the private key.

HalfOwnerResponsibility
Controller + private keyRCS platform teamRuns the sealed-secrets-controller in the sealed-secrets namespace, holds the private key, rotates it, and decrypts your SealedSecrets into Secrets.
kubeseal CLI + SealedSecretsYou (the tenant)Install kubeseal, author Secrets, seal them into SealedSecrets, and commit those to your repo in a tenant-prefixed namespace.

You have Capsule admin RBAC inside your own tenant-prefixed namespaces (for example <your-tenant>-prod), so you can create SealedSecrets there and read the resulting Secret. Replace <your-tenant> with your RAP group name throughout this page.

kubeseal is a standalone binary, not a kubectl plugin — you run kubeseal, not kubectl seal, and it is not distributed via Krew. Install version 0.28.0 or newer to match the controller running on Kestrel (Helm chart sealed-secrets 2.16.1, CLI pin 0.28.0). Releases are published at bitnami-labs/sealed-secrets.

The recommended path on macOS is Homebrew:

Terminal window
brew install kubeseal
kubeseal --version

Alternatively, install via MacPorts (sudo port install kubeseal), or download the kubeseal-<version>-darwin-amd64.tar.gz (or -arm64) archive from the release page, extract it, and put the kubeseal binary on your PATH.

Verify any install with kubeseal --version — it should print 0.28.0 or higher.

The model is: author a normal Secret → pipe it through kubeseal → get a SealedSecret → commit and let ArgoCD apply it. The plaintext file never leaves your machine.

Write a normal Secret manifest targeting your tenant-prefixed namespace. Use stringData for plain strings — Kubernetes base64-encodes them for you (use data only for values that are already base64, and never double-encode).

# This is plaintext — do NOT commit it, and delete it after sealing.
apiVersion: v1
kind: Secret
metadata:
name: db-password
namespace: <your-tenant>-prod # Must be your tenant-prefixed namespace
type: Opaque
stringData:
password: "s3cr3t-value"

Pipe the plaintext through kubeseal. On Kestrel the only flag you need is --controller-namespace sealed-secrets:

Terminal window
kubeseal --format yaml --controller-namespace sealed-secrets \
< secret.yaml > sealedsecret.yaml

kubeseal defaults --controller-name to sealed-secrets-controller, which already matches the controller’s Service name on Kestrel — so you never set that flag. What it gets wrong by default is the namespace (kube-system), which is why --controller-namespace sealed-secrets is required. Without --cert, kubeseal fetches the controller’s public certificate at runtime over your normal kubectl connection — the same Kestrel capsule-proxy your other commands use — so if kubectl get ns works, sealing works. The certificate is public and is only used to encrypt; the private key never leaves the controller.

The output is a SealedSecret whose values live under encryptedData. This is what you commit — only Kestrel’s controller can decrypt it.

# You own this — safe to commit to your own repo
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: db-password
namespace: <your-tenant>-prod
spec:
encryptedData:
password: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq4kf8w... # ciphertext, not the value
template:
metadata:
name: db-password
namespace: <your-tenant>-prod
type: Opaque

The template block describes the Secret the controller will produce — same name, same namespace, same type.

Now apply the SealedSecret to the cluster and delete the plaintext secret.yaml. The recommended path is GitOps: commit sealedsecret.yaml to your repo alongside the workload that uses it and push — ArgoCD applies it on the next sync. If you don’t manage this resource with ArgoCD, apply it directly instead:

Terminal window
kubectl apply -f sealedsecret.yaml -n <your-tenant>-prod

Either way, the controller decrypts the SealedSecret into a Secret within a few seconds. Reference that Secret from your workload as you normally would:

# In your Deployment's container spec
envFrom:
- secretRef:
name: db-password
# or pull a single key:
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-password
key: password

If you deploy through ArgoCD, the Secret only exists after the controller has processed the SealedSecret — so add a sync-wave annotation to the SealedSecret so ArgoCD applies it before the workloads that consume it:

metadata:
annotations:
argocd.argoproj.io/sync-wave: "-1"

Once ArgoCD has synced, both resources should be present in your namespace:

Terminal window
kubectl get sealedsecret -n <your-tenant>-prod # the SealedSecret you committed
kubectl get secret -n <your-tenant>-prod # the derived Secret (controller-owned)

Whether you applied the SealedSecret through ArgoCD or with kubectl apply, the result is identical — this is the verified flow on Kestrel: apply a SealedSecret, and a matching Secret appears within a few seconds.

A scope decides what name and namespace a sealed value is bound to — that is, the conditions under which the controller will agree to decrypt it. Set it with --scope, or by annotating the input Secret.

ScopeFlagBound toCan you rename or move it?
strict (default, recommended)omit, or --scope strictname and namespaceNo — rename or move means you must re-seal.
namespace-wide--scope namespace-widenamespace onlyRename freely within the same namespace.
cluster-wide--scope cluster-widenothingAny name, any namespace.

Use the default strict scope. It is the most restrictive — a sealed value only decrypts under the exact name and namespace it was sealed for, so a leaked SealedSecret cannot be re-targeted at another namespace. The trade-off is that renaming the Secret or moving it to a different namespace requires re-sealing (see Updating a secret value). RCS internal automation sometimes uses namespace-wide; that is an RCS choice, not the tenant default.

The standard flow fetches the public certificate from the controller, which needs cluster access. For CI/CD pipelines — or any machine that cannot reach the cluster — seal offline against a copy of the public certificate instead. This is the platform’s documented method.

First, obtain the public certificate once. Anyone with controller access can fetch it, or you can ask RCS for the published copy:

Terminal window
# Run once by someone who can reach the controller; the cert is public and safe to share.
kubeseal --controller-namespace sealed-secrets --fetch-cert > kestrel-pub-cert.pem

Then seal anywhere with --cert, no kubeconfig required:

Terminal window
kubeseal --cert ./kestrel-pub-cert.pem --format yaml \
< secret.yaml > sealedsecret.yaml

--cert accepts either a local file path or an HTTPS URL, so a CI runner can point straight at a published cert without baking in a local copy. When you pass --cert, kubeseal never contacts the cluster — but scope binding still applies, so set metadata.namespace and metadata.name (or --scope) correctly even offline. Because the controller retains old keys, a certificate RCS published earlier keeps working for new seals; re-fetch it occasionally as hygiene rather than hard-pinning a years-old copy.

There is no in-place edit of an encrypted value — you re-seal and re-commit. Edit the plaintext Secret, run kubeseal again, overwrite the committed sealedsecret.yaml, and push. ArgoCD applies the new SealedSecret and the controller re-derives the Secret:

Terminal window
kubeseal --format yaml --controller-namespace sealed-secrets \
< secret.yaml > sealedsecret.yaml # overwrite the committed file, then commit + push

To change a single value inside a larger secret without re-writing the whole manifest, --raw encrypts one value and prints only the ciphertext, which you paste into spec.encryptedData. Raw output carries no metadata, so you must supply the binding fields explicitly — for strict scope that means both --namespace and --name:

Terminal window
echo -n "new-s3cr3t-value" | kubeseal --raw \
--controller-namespace sealed-secrets \
--scope strict \
--namespace <your-tenant>-prod \
--name db-password
# → prints ciphertext to paste into encryptedData

cannot fetch certificate / 403 / forbidden when sealing

Section titled “cannot fetch certificate / 403 / forbidden when sealing”

Symptom: kubeseal fails before producing output with a certificate-fetch error, a 403, or a forbidden message.

Cause: kubeseal fetches the controller’s public certificate over your kubectl connection through the Kestrel capsule-proxy. Tenant access through the proxy is the verified path, so a failure here almost always means your kubeconfig isn’t pointed at the Kestrel proxy context, your kubelogin session isn’t active, or you’re on a machine (such as a CI runner) with no cluster access at all. Quick check: if kubectl get ns works, the cert fetch will too.

Fix: if kubectl itself isn’t working, fix that first — see Install kubelogin. For CI runners or any environment without cluster access, seal offline with --cert instead: have someone with access run kubeseal --controller-namespace sealed-secrets --fetch-cert (or ask RCS for the published cert), then follow Sealing offline.

SealedSecret applied but no Secret appears

Section titled “SealedSecret applied but no Secret appears”

Symptom: kubectl get sealedsecret -n <your-tenant>-prod shows your resource, but kubectl get secret -n <your-tenant>-prod never shows the derived Secret.

Cause: with the default strict scope the value is bound to the exact name and namespace present in the plaintext Secret at seal time. If you sealed for one namespace or name but the SealedSecret ends up under a different one (you renamed it, moved it, or sealed with the wrong metadata.namespace), the bound label no longer matches and the controller refuses to decrypt.

Fix: confirm the namespace and name in your plaintext Secret matched the namespace and name the SealedSecret actually lives under before you sealed, then re-seal. The values you seal for and the values you apply must be identical.

Controller log: no key could decrypt secret

Section titled “Controller log: no key could decrypt secret”

Symptom: the controller logs no key could decrypt secret and the Secret is never created.

Cause: the value was sealed against a different cluster or certificate, or a strict-scope name/namespace mismatch means no retained key matches.

Fix: re-seal against Kestrel’s current certificate with the exact target name and namespace. If you keep a local cert for offline sealing, make sure it is Kestrel’s — re-fetch it with --fetch-cert if unsure.

Derived Secret shows OutOfSync or keeps getting pruned in ArgoCD

Section titled “Derived Secret shows OutOfSync or keeps getting pruned in ArgoCD”

Symptom: the decrypted Secret flips to OutOfSync, or ArgoCD prunes and recreates it on every sync.

Cause: the derived Secret is committed to Git or otherwise managed by an ArgoCD Application. ArgoCD and the controller both claim ownership and fight over it.

Fix: remove the derived Secret from Git and manage only the SealedSecret. The controller owns the Secret; ArgoCD should never see it in your manifests.

With secrets sealed and committed, wire them into the rest of your GitOps setup: