Store Kubernetes secrets in Git with Sealed Secrets

Most of a Kubernetes deployment lives in Git. Deployments, Services and ConfigMaps are versioned, reviewed and applied the same way every time. Secrets are the exception: they get set up out of band by whoever built the environment, and the repository ends up describing a cluster it cannot actually rebuild.

Start by checking whether you need a stored credential at all. Every pod in a CFKE cluster is issued a short-lived OIDC token, and any service that can validate an external OIDC issuer accepts it in place of a key. That covers a lot of the common cases: reaching S3, Cloud Storage or Blob Storage needs no access key of its own. See accessing cloud APIs securely. A credential that does not exist cannot leak, so it is worth ruling this out before looking for somewhere to put one.

If you already run a secret manager, that is the better place to solve this. AWS Secrets Manager, Google Secret Manager and Azure Key Vault cover the hyperscalers, and HashiCorp Vault or OpenBao, its Linux Foundation fork, cover self-hosting. Point External Secrets Operator at any of them and the credential never enters your repository at all.

This guide covers the other answer, for teams that would rather not run a second control plane for a dozen passwords: encrypt the Secret so that committing it is safe. It starts with how that compares to the alternatives, then builds a working example with Sealed Secrets, which encrypts against a public key that only the cluster can reverse.

What your options are

The approaches fall into three groups.

Keep secrets out of the cluster entirely

Store credentials in a dedicated secret manager and have workloads read them at runtime, or have an operator sync them in. External Secrets Operator (CNCF Incubating) reads from AWS Secrets Manager, Google Secret Manager, Azure Key Vault, HashiCorp Vault, OpenBao and others, and creates the Kubernetes Secret for you. The Secrets Store CSI driver mounts values as files instead.

This is the strongest option and the one to pick if you need it. The secret manager gives you an audit trail of every read, automatic rotation, fine-grained IAM, and a single source of truth across clusters. On CFKE it also composes well with OIDC federation, so the cluster authenticates with its own identity and there is no bootstrap credential to manage.

The cost is a dependency and a bill. You are running an operator, maintaining IAM policy in a second system, and depending on the secret manager’s availability at pod start. For a three-person team with a dozen credentials that change twice a year, that machinery is heavier than the problem.

Encrypt secrets and store them in Git

Keep the credential in the repository, encrypted, so the repository stays the source of truth. Two tools dominate:

  • SOPS encrypts values inside any YAML or JSON file, leaving the keys readable so diffs stay useful. It backs onto age, PGP or a cloud KMS. It is not Kubernetes-specific, which makes it good for Terraform variables and CI config too, and Flux integrates with it directly.
  • Sealed Secrets is Kubernetes-specific. A controller in the cluster generates a keypair and publishes the public half. The kubeseal CLI encrypts a Secret against that public key into a SealedSecret custom resource, and only the controller can decrypt it.

The difference that matters: with SOPS, whoever deploys needs the decryption key, so the key has to live somewhere in CI. With Sealed Secrets, nobody needs a decryption key, including your CI system. Encryption uses the public certificate, so a developer can seal a value they will never be able to read back, and a compromised CI runner has nothing to steal. That property is why this guide uses it.

The cost is that you get no read audit trail, no automatic rotation, and rotating a credential means a commit. For a small team, those are acceptable trade-offs. For a regulated environment that needs to answer “who read this key and when”, they are not, and you want the first group.

Do neither and restrict access instead

Leave Secrets unencrypted and rely on RBAC plus encryption at rest to protect them. This is a real answer for a single cluster with a small, trusted operator group, and it is what you fall back on for values that are generated in-cluster and never leave it, like a service mesh CA. It leaves the repository unable to describe the cluster, so it does not scale past one environment.

How to decide

Pick the secret manager if you already run one, if you need rotation or read audit trails, or if the same credential has to reach several clusters. Pick Sealed Secrets if your repository is the source of truth, your team is small enough that “rotate by commit” is fine, and you would rather not run a second control plane for a handful of passwords. The two coexist without conflict, so starting with Sealed Secrets does not block moving later.

Why this matters

Committing an encrypted Secret costs a little extra discipline. Three things make it worth paying for.

A Secret manifest in Git is a credential in Git. The data field is base64, an encoding rather than a cipher, so an unencrypted Secret in a repository is a readable credential. Once it is committed it is in every clone, every fork, every CI cache and every developer’s laptop, and rewriting history reaches none of them. The only safe response is to rotate the credential, because you cannot prove nobody fetched it.

Keeping Secrets out of Git creates a different problem. If Secrets are applied by hand, nothing in your repository describes the full state of the cluster. A rebuild is not reproducible, kubectl diff is misleading, and GitOps tools like Argo CD or Flux report drift they cannot fix. Teams work around this with a shared password manager and a runbook, which is exactly the manual step that gets skipped at 2am.

The blast radius is wider than the one service. A database URL usually reaches more than the app that holds it, and a cloud API key usually has broader permissions than anyone remembers granting.

Encrypting gets you both halves. The credential goes through review and has a history, and there is nothing readable to leak if the repository does.

How it works

flowchart TD
    Plain["Secret<br/>(plaintext, never saved)"] -->|"kubeseal, public cert"| Sealed["SealedSecret<br/>(ciphertext)"]
    Sealed -->|git commit| Git[("Git repository")]
    Git -->|kubectl apply| API["Kubernetes API"]
    API --> Ctrl["sealed-secrets controller<br/>(holds the private key)"]
    Ctrl -->|decrypts| Secret["Secret"]
    Secret --> Pod["Pod"]

The controller generates a 4096-bit RSA keypair on first start and stores it as a TLS Secret in its own namespace. kubeseal fetches the public certificate and encrypts locally, so the plaintext never leaves your machine and never reaches the cluster. When a SealedSecret is applied, the controller decrypts it and creates a normal Secret owned by the SealedSecret, which means deleting the SealedSecret deletes the Secret, and deleting the Secret by hand gets it recreated.

Encryption is bound to the Secret’s namespace and name by default, so a SealedSecret copied into another namespace will not decrypt. That is what stops a developer who can create resources in their own namespace from unsealing production’s database password.

Prerequisites

  • A running CFKE cluster with a fleet attached. See the getting started guide if you need one.
  • kubectl and helm on your local machine. See the CLI configuration guide for cluster access.
  • kubeseal, the client that does the encrypting. On macOS brew install kubeseal; on Linux take the binary from the releases page. Keep it on the same minor version as the controller.

This guide applies a single manifest with kubectl so the mechanics stay visible. Nothing about it depends on that: a SealedSecret is an ordinary custom resource, so Argo CD, Flux and Kustomize handle it exactly like the Deployment next to it.

Step 1: Install the controller

The chart is published at bitnami.github.io/sealed-secrets. Older guides point at bitnami-labs.github.io, which no longer serves an index.

bash
helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets
helm repo update

Install into kube-system under the name sealed-secrets-controller. Those are the defaults kubeseal looks for, so matching them means you never have to pass --controller-name or --controller-namespace:

bash
helm install sealed-secrets sealed-secrets/sealed-secrets \
  --namespace kube-system \
  --set fullnameOverride=sealed-secrets-controller \
  --wait

Confirm the controller is running and the custom resource is registered:

bash
kubectl get pods -n kube-system -l app.kubernetes.io/name=sealed-secrets
kubectl get crd sealedsecrets.bitnami.com
NAME                                         READY   STATUS    RESTARTS   AGE
sealed-secrets-controller-7649b8b55f-bvsnz   1/1     Running   0          2m3s
NAME                        CREATED AT
sealedsecrets.bitnami.com   2026-08-28T10:12:45Z

On the first start the controller creates its keypair. It is a TLS Secret in kube-system, labelled so it can be found again:

bash
kubectl get secrets -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key
NAME                      TYPE                DATA   AGE
sealed-secrets-keys6fq9   kubernetes.io/tls   2      95s

That Secret is the only thing that can decrypt anything you seal. Back it up before you go any further; Step 7 covers how.

Step 2: Fetch the public certificate

kubeseal needs the controller’s public certificate to encrypt. It can pull it from the cluster on every run, but fetching it once to a file is better: sealing then works offline, on a laptop with no cluster credentials, and in CI.

bash
kubeseal --fetch-cert > sealed-secrets.pem
bash
openssl x509 -in sealed-secrets.pem -noout -dates -text | grep -E "Not (Before|After)|Public-Key"
            Not Before: Aug 28 10:14:39 2026 GMT
            Not After : Aug 25 10:14:39 2036 GMT
                Public-Key: (4096 bit)

This file is a public key. Commit it to the repository alongside your manifests. It is what lets a new team member seal a value on their first day without cluster access, and it is not a credential.

Step 3: Seal a secret

Create the namespace the Secret will live in:

bash
kubectl create namespace demo

Now the part that matters. Generate the Secret with --dry-run=client and pipe it straight into kubeseal, so the plaintext exists only in the pipe:

bash
kubectl create secret generic app-credentials \
  --namespace demo \
  --from-literal=DATABASE_URL='postgres://app:[email protected]:5432/app' \
  --dry-run=client -o yaml \
  | kubeseal --format yaml --cert sealed-secrets.pem \
  > sealed-app-credentials.yaml

--dry-run=client means kubectl renders the manifest and sends nothing to the API server. No plaintext Secret is ever created, and no plaintext file is written.

The result is the file you commit:

yaml
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: app-credentials
  namespace: demo
spec:
  encryptedData:
    DATABASE_URL: AgA9daarqsCGLbaDkkkBeA/2DhVZ0HGbUhZKnaqHVD/yOJSNzNeKZ6kFHCUid2vwWrw3IJbVSnMhAXBr…
  template:
    metadata:
      name: app-credentials
      namespace: demo

The ciphertext is truncated here; the real value is one long line of about 700 characters, because each value is encrypted separately with RSA-4096. Values are encrypted one by one rather than as a block, which is what allows a diff to show that only one credential changed.

The template block is the skeleton of the Secret the controller will create. Anything you would normally set on a Secret goes there and stays readable: type: kubernetes.io/dockerconfigjson, labels, annotations.

Step 4: Apply it and watch the Secret appear

bash
kubectl apply -f sealed-app-credentials.yaml
bash
kubectl get sealedsecret app-credentials -n demo
kubectl get secret app-credentials -n demo
NAME              STATUS   SYNCED   AGE
app-credentials            True     4s
NAME              TYPE     DATA   AGE
app-credentials   Opaque   1      4s

SYNCED: True means the controller decrypted it. The Secret is a normal Secret:

bash
kubectl get secret app-credentials -n demo -o jsonpath='{.data.DATABASE_URL}' | base64 -d
postgres://app:[email protected]:5432/app

It also carries an owner reference back to the SealedSecret:

bash
kubectl get secret app-credentials -n demo -o jsonpath='{.metadata.ownerReferences[0].kind}'
SealedSecret

That ownership is what makes the setup self-healing. Delete the Secret and the controller recreates it from the SealedSecret within seconds. Delete the SealedSecret and the Secret goes with it.

Step 5: Use it from a workload

Nothing about consuming the Secret is special, which is the point. Workloads never learn that Sealed Secrets is involved:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo-app
  namespace: demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: demo-app
  template:
    metadata:
      labels:
        app: demo-app
    spec:
      containers:
        - name: app
          image: busybox:1.37
          command: ["sh", "-c", "echo \"connecting to $DATABASE_URL\"; sleep 3600"]
          envFrom:
            - secretRef:
                name: app-credentials
          resources:
            requests:
              cpu: 10m
              memory: 32Mi
bash
kubectl apply -f deployment.yaml
kubectl rollout status deploy/demo-app -n demo
kubectl logs -n demo deploy/demo-app
connecting to postgres://app:[email protected]:5432/app

Both manifests are now safe to commit to the same directory.

Step 6: Understand the scope

Try applying the same file into a different namespace:

bash
kubectl create namespace other
sed 's/namespace: demo/namespace: other/' sealed-app-credentials.yaml | kubectl apply -f -

It is accepted, and then it fails:

bash
kubectl get sealedsecret app-credentials -n other -o jsonpath='{.status.conditions[0].message}'
no key could decrypt secret (DATABASE_URL)

No Secret is created, and the controller emits an ErrUnsealFailed event. The namespace and name were mixed into the encryption, so moving the file breaks it. This is deliberate: it means a SealedSecret you can read in Git is not a credential you can use.

Two other scopes relax that, and both are worth knowing about mostly so you recognise them:

ScopeBound tokubeseal flag
strict (default)namespace and namenone
namespace-widenamespace only--scope namespace-wide
cluster-widenothing--scope cluster-wide

namespace-wide is the useful one, for a value that several differently-named Secrets in one namespace share. cluster-wide removes the protection entirely: anyone who can create a Secret anywhere can unseal it under a name of their choosing. Use it only for values that are not really secret.

Stay on strict unless you have a reason not to. The consequence to plan for is that renaming a Secret or moving it between namespaces means resealing it, which is fine when the plaintext is still in your password manager and painful when it is not.

Step 7: Back up the sealing key

If you lose the controller’s private key, every SealedSecret in your repository becomes unreadable, permanently. There is no recovery path. Rebuilding the cluster without a backup means resealing every credential from the original values, which you may no longer have.

bash
kubectl get secret -n kube-system \
  -l sealedsecrets.bitnami.com/sealed-secrets-key \
  -o yaml > sealing-key-backup.yaml

This file contains the private key. It is the one artefact in this entire guide that must not go into Git. Put it in your password manager or an offline vault, and treat access to it as equivalent to access to every credential you have ever sealed.

To restore into a rebuilt cluster, apply it before or after installing the controller, then restart the controller so it picks the key up:

bash
kubectl apply -f sealing-key-backup.yaml
kubectl delete pod -n kube-system -l app.kubernetes.io/name=sealed-secrets

The controller loads every labelled key it finds, so restoring an old key alongside a newer one is safe and is how you migrate a repository of SealedSecret files to a new cluster.

Step 8: Rotate

Two different things get called rotation, and doing only the first is a common mistake.

Sealing key renewal happens on its own every 30 days. The controller creates a new keypair, starts sealing new secrets with it, and keeps the old keys so existing SealedSecret files keep working:

NAME                      TYPE                DATA   AGE
sealed-secrets-keys6fq9   kubernetes.io/tls   2      3m17s
sealed-secrets-keyt57nn   kubernetes.io/tls   2      15s

The certificate changes when the key does, so refetch it. The old key stays valid, which means a stale sealed-secrets.pem keeps working silently and binds every new secret to the key you were trying to move away from:

bash
kubeseal --fetch-cert > sealed-secrets.pem

To bring existing files onto the newest key, re-encrypt them. This needs cluster access, since it round-trips through the controller, but it never exposes plaintext:

bash
kubeseal --re-encrypt --format yaml \
  < sealed-app-credentials.yaml \
  > sealed-app-credentials.new.yaml

You can force a renewal early, which is what you do the moment you suspect the key leaked:

bash
kubectl set env deploy/sealed-secrets-controller -n kube-system \
  SEALED_SECRETS_KEY_CUTOFF_TIME="$(date -R)"

Credential rotation is changing the password itself, and renewal does nothing for it. If the sealing key leaked, every value ever sealed with it is compromised, and a new sealing key does not un-leak them. Renew the key first, then change every credential at its source and reseal. Doing it in the other order seals the new credentials with the compromised key.

To update one value in place, without reconstructing the whole Secret, seal a single value with --raw. Read it with read -rs so it is neither echoed to the terminal nor written to your shell history, and pipe it in rather than passing it as an argument:

bash
read -rs NEW_VALUE
CIPHER=$(printf '%s' "$NEW_VALUE" \
  | kubeseal --raw --cert sealed-secrets.pem \
      --namespace demo --name app-credentials --from-file=/dev/stdin)
unset NEW_VALUE

--raw prints just the ciphertext, so replace the matching line under encryptedData in the committed file with $CIPHER. The namespace and name have to match the target Secret, because they are part of what gets encrypted. To add a key rather than replace one, --merge-into edits the file for you and leaves the other ciphertexts untouched, so the diff shows exactly one changed line:

bash
kubectl create secret generic app-credentials -n demo \
  --from-literal=API_KEY='ak_live_9f2b' \
  --dry-run=client -o yaml \
  | kubeseal --format yaml --cert sealed-secrets.pem \
      --merge-into sealed-app-credentials.yaml

Keeping plaintext out of the repository

This is the failure mode that matters. Sealed Secrets does not protect you from committing the unencrypted Secret next to the sealed one, and that mistake is easy to make: you write secret.yaml to check something, get distracted, and git add . does the rest. Nothing about the workflow is unsafe, but the intermediate files are.

Five guards, in order of how much they buy you.

Never write the plaintext down. The pipeline in Step 3 is the whole technique. kubectl create secret --dry-run=client -o yaml | kubeseal produces the sealed file with no plaintext file in between, so there is nothing to leak.

It still leaks somewhere else, though. --from-literal puts the credential in argv, where it is visible to ps and lands in ~/.zsh_history. The examples above use it because it reads clearly in a guide. For a credential you actually care about, read it into a variable and hand it over on a file descriptor:

bash
read -rs DB_URL
kubectl create secret generic app-credentials --namespace demo \
  --from-file=DATABASE_URL=<(printf '%s' "$DB_URL") \
  --dry-run=client -o yaml \
  | kubeseal --format yaml --cert sealed-secrets.pem \
  > sealed-app-credentials.yaml
unset DB_URL

read -rs does not echo the value or record it in history, and <(...) passes a file descriptor rather than the value, so nothing sensitive reaches argv. printf rather than echo keeps a trailing newline out of the secret.

Ignore the files you cannot avoid. Some tools only write files. Give them a naming convention and ignore it globally, not per-repository:

gitignore
*.plain.yaml
*.secret.yaml
secrets/

Reject the commit. An ignore rule fails open, so add a hook that fails closed. A SealedSecret is kind: SealedSecret, so anything that reached kind: Secret is a manifest that was never sealed:

bash
#!/bin/sh
# Refuse to commit a plaintext Kubernetes Secret. SealedSecret manifests are
# kind: SealedSecret, so they pass; anything at kind: Secret was never sealed.
found=$(git diff --cached --name-only --diff-filter=ACM -z \
  | xargs -0 -I{} sh -c 'git show ":{}" 2>/dev/null | grep -lq "^kind: Secret$" - && echo "{}"')

if [ -n "$found" ]; then
  echo "pre-commit: refusing to commit plaintext Secret manifests:" >&2
  echo "$found" >&2
  echo "Seal them with kubeseal, or pass --no-verify if you are certain." >&2
  exit 1
fi

Save it as .githooks/pre-commit, then chmod +x .githooks/pre-commit and git config core.hooksPath .githooks. Because it is committed to the repository, everyone gets it after one git config, unlike a hook in .git/hooks.

Scan for credentials generally. The hook only catches Secret manifests, and credentials leak into ConfigMaps, Helm values and .env files too. gitleaks covers the rest, in CI and as a second pre-commit step.

It needs one adjustment. SealedSecret ciphertext is long, high-entropy base64, so the default generic-api-key rule flags every sealed file you commit:

generic-api-key | sealed-app-credentials.yaml | line 9 | Detected a Generic API Key

False positives on every commit train people to ignore the tool, which is worse than not running it. Ciphertext always starts with Ag, so allowlist that shape in .gitleaks.toml:

toml
[extend]
useDefault = true

# SealedSecret ciphertext is base64 that trips the generic high-entropy rules.
# It is already encrypted, so it is not a finding.
[[allowlists]]
description = "sealed-secrets ciphertext"
regexTarget = "match"
regexes = ['''Ag[A-Za-z0-9+/=]{80,}''']

Verify the allowlist still catches real leaks before you trust it. A plaintext Secret in the same directory must still fail the scan.

Enforce it in CI. A hook lives on one laptop and git --no-verify walks straight past it. CI is the guard that actually holds, because it runs on the pull request and nobody can skip it. Save this as .github/workflows/secret-scan.yml:

yaml
name: Secret scan

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          # gitleaks scans history, not just the current tree
          fetch-depth: 0

      - name: Reject plaintext Secret manifests
        run: |
          if git ls-files -z '*.yaml' '*.yml' | xargs -0 grep -l '^kind: Secret$'; then
            echo "::error::Plaintext Secret manifests are tracked. Seal them with kubeseal."
            exit 1
          fi          

      - name: Scan for credentials
        run: |
          curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz" \
            | tar -xz gitleaks
          ./gitleaks git . --redact --config .gitleaks.toml          

The first step is the same check as the pre-commit hook, applied to every tracked file rather than the staged ones. The second installs the gitleaks binary directly. That is deliberate: the official gitleaks/gitleaks-action requires a licence key for repositories owned by an organisation, free but one more thing to provision, and the binary has no such requirement.

fetch-depth: 0 is the part that is easy to get wrong. Without it, actions/checkout fetches a single commit and gitleaks has no history to scan, so the job passes while the leak sits three commits back. It is worth understanding what that buys you:

CheckVerdict on a secret committed, then deleted later
kind: Secret check on tracked filespasses
gitleaks with fetch-depth: 0fails

A credential that was committed and then removed is still in the repository, and still compromised. The history scan is what surfaces it. This is also why deleting the file is not remediation.

If your repository is on GitHub, turn on push protection as well. It blocks recognised provider tokens at push time, before CI ever runs, and it costs nothing on public repositories.

If something does get committed anyway, rotate it. Rewriting history does not reach the clones, forks and CI caches that already have it.

What this does and does not give you

It gives you: a repository that fully describes the cluster, including credentials; secrets that go through code review with a diff that shows which one changed; no decryption key anywhere outside the cluster, including in CI; and encryption that a developer can perform without being able to decrypt.

It does not give you:

  • An audit trail of reads. Git records who changed a sealed value. Nothing records who read the decrypted Secret. If you need that, use a secret manager.
  • Automatic rotation. Every credential change is a manual reseal and a commit.
  • Protection from cluster-level access. The controller creates an ordinary Secret. Anyone with get secrets in that namespace reads it in plaintext, and anyone who can read the sealing key in kube-system decrypts everything. RBAC is doing the real work here; Sealed Secrets protects the credential in Git, not in the cluster.
  • Authentication of who sealed a value. The public certificate is public by design, so anyone holding it can produce a valid SealedSecret for any name and namespace. Whether it gets applied is a question for your repository’s review rules and your cluster’s RBAC, not for the controller.
  • A way back. Lose the sealing key with no backup and the ciphertext is gone for good. This is worth repeating because it is the one mistake with no remedy.

Cleaning up

bash
kubectl delete namespace demo other
helm uninstall sealed-secrets -n kube-system
kubectl delete crd sealedsecrets.bitnami.com

Uninstalling the chart leaves the sealing key Secret in kube-system. That is deliberate, so a reinstall keeps working. Delete it only if you are sure you will never need to decrypt those files again:

bash
kubectl delete secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key

Next steps

  • Point Argo CD or Flux at the repository. SealedSecret resources need no special handling, and the controller reconciles them independently of the GitOps tool.
  • Restrict who can read the decrypted Secrets with RBAC, which is what actually protects them inside the cluster.
  • If you outgrow “rotate by commit”, move to External Secrets Operator. The two run side by side, so you can migrate one credential at a time.
On this page