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.
What RCS manages vs. what you do
Section titled “What RCS manages vs. what you do”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.
| Half | Owner | Responsibility |
|---|---|---|
| Controller + private key | RCS platform team | Runs the sealed-secrets-controller in the sealed-secrets namespace, holds the private key, rotates it, and decrypts your SealedSecrets into Secrets. |
kubeseal CLI + SealedSecrets | You (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.
Install kubeseal
Section titled “Install kubeseal”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:
brew install kubesealkubeseal --versionAlternatively, 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.
The recommended path on Linux is to download the release tarball, which lets you pin the exact version the platform expects:
KUBESEAL_VERSION='0.28.0' # or newercurl -OL "https://github.com/bitnami-labs/sealed-secrets/releases/download/v${KUBESEAL_VERSION}/kubeseal-${KUBESEAL_VERSION}-linux-amd64.tar.gz"tar -xvzf "kubeseal-${KUBESEAL_VERSION}-linux-amd64.tar.gz" kubesealsudo install -m 755 kubeseal /usr/local/bin/kubesealkubeseal --versionAlternatively, install via Linuxbrew (brew install kubeseal) or Nix (nix-env -iA nixpkgs.kubeseal). Package managers install the latest release rather than a pinned version, so prefer the tarball if you need a specific one.
The recommended path on Windows is Scoop:
scoop install kubesealAlternatively, install via Chocolatey (choco install kubeseal — if that ID does not resolve, try choco install sealed-secrets). For a guaranteed pinned install, download the official kubeseal-<version>-windows-amd64.tar.gz from the release page, extract kubeseal.exe, and put it on your PATH:
$ver = "0.28.0"curl.exe -OL "https://github.com/bitnami-labs/sealed-secrets/releases/download/v$ver/kubeseal-$ver-windows-amd64.tar.gz"tar -xvzf "kubeseal-$ver-windows-amd64.tar.gz" kubeseal.exeVerify any install with kubeseal --version — it should print 0.28.0 or higher.
Seal a secret
Section titled “Seal a secret”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.
1. Author a plaintext Secret
Section titled “1. Author a plaintext Secret”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: v1kind: Secretmetadata: name: db-password namespace: <your-tenant>-prod # Must be your tenant-prefixed namespacetype: OpaquestringData: password: "s3cr3t-value"2. Seal it
Section titled “2. Seal it”Pipe the plaintext through kubeseal. On Kestrel the only flag you need is --controller-namespace sealed-secrets:
kubeseal --format yaml --controller-namespace sealed-secrets \ < secret.yaml > sealedsecret.yamlkubeseal 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.
3. Inspect the result
Section titled “3. Inspect the result”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 repoapiVersion: bitnami.com/v1alpha1kind: SealedSecretmetadata: name: db-password namespace: <your-tenant>-prodspec: encryptedData: password: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq4kf8w... # ciphertext, not the value template: metadata: name: db-password namespace: <your-tenant>-prod type: OpaqueThe template block describes the Secret the controller will produce — same name, same namespace, same type.
4. Commit, sync, and consume
Section titled “4. Commit, sync, and consume”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:
kubectl apply -f sealedsecret.yaml -n <your-tenant>-prodEither 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 specenvFrom: - secretRef: name: db-password# or pull a single key:env: - name: DB_PASSWORD valueFrom: secretKeyRef: name: db-password key: passwordIf 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"5. Verify
Section titled “5. Verify”Once ArgoCD has synced, both resources should be present in your namespace:
kubectl get sealedsecret -n <your-tenant>-prod # the SealedSecret you committedkubectl 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.
Scopes
Section titled “Scopes”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.
| Scope | Flag | Bound to | Can you rename or move it? |
|---|---|---|---|
| strict (default, recommended) | omit, or --scope strict | name and namespace | No — rename or move means you must re-seal. |
| namespace-wide | --scope namespace-wide | namespace only | Rename freely within the same namespace. |
| cluster-wide | --scope cluster-wide | nothing | Any 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.
Sealing offline (CI/CD)
Section titled “Sealing offline (CI/CD)”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:
# 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.pemThen seal anywhere with --cert, no kubeconfig required:
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.
Updating a secret value
Section titled “Updating a secret value”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:
kubeseal --format yaml --controller-namespace sealed-secrets \ < secret.yaml > sealedsecret.yaml # overwrite the committed file, then commit + pushTo 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:
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 encryptedDataTroubleshooting
Section titled “Troubleshooting”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.
Next steps
Section titled “Next steps”With secrets sealed and committed, wire them into the rest of your GitOps setup:
- Your repo, your workloads — where the
SealedSecretlives in your repo and how sync defaults apply to it. - ArgoCD on Kestrel — watch the
SealedSecretsync and the derivedSecretappear in the resource tree. - Long-running services — a Deployment recipe that consumes the
SecretviaenvFromandsecretKeyRef.