One of the first questions beginners ask when learning GitOps is:
"If everything goes in Git, where do I put passwords and API keys?"
You can't just commit a Kubernetes Secret to Git. It's base64 encoded — not encrypted. Anyone with repo access can decode it in seconds.
This tutorial shows you exactly how to solve this using Sealed Secrets and Flux — two tools that let you safely store secrets in Git without exposing sensitive values.
What You'll Learn
Why plain Kubernetes Secrets are unsafe in Git
What Sealed Secrets is and how it works
How to install Sealed Secrets with Flux
How to create and deploy your first sealed secret
Best practices to avoid common beginner mistakes
Prerequisites
Before starting, make sure you have:
A running Kubernetes cluster (local with kind or minikube works fine)
kubectl installed and configured
Flux CLI installed — install guide here
A Git repository (GitHub, GitLab, etc.) bootstrapped with Flux
If you haven't bootstrapped Flux yet, run:
bashflux bootstrap github \
--owner= \
--repository= \
--branch=main \
--path=./clusters/my-cluster \
--personal
Why You Can't Just Commit a Kubernetes Secret
A standard Kubernetes Secret looks like this:
yamlapiVersion: v1
kind: Secret
metadata:
name: my-app-secret
type: Opaque
data:
password: c3VwZXJzZWNyZXQ=
That c3VwZXJzZWNyZXQ= value looks like gibberish. But it's just base64 encoding — not encryption. Decode it in your terminal:
bashecho "c3VwZXJzZWNyZXQ=" | base64 --decode
Output: supersecret
Anyone who can read your Git repo can decode every secret in it. This is the core problem GitOps beginners run into.
What Is Sealed Secrets?
Sealed Secrets is a Kubernetes controller created by Bitnami. It solves this problem with a simple approach:
You encrypt your secret using a public key — producing a SealedSecret resource
You commit the SealedSecret to Git — it's safe because it's encrypted
When Flux applies it to your cluster, the Sealed Secrets controller decrypts it using its private key (which never leaves the cluster)
Kubernetes gets the actual Secret — your app works normally
The critical point: only the controller inside your cluster can decrypt it. Even if someone steals your Git repo, they can't decrypt the sealed secrets without access to the cluster's private key.
Step 1: Install the Sealed Secrets Controller via Flux
With GitOps, you don't install things manually with helm install. You declare everything in Git and let Flux apply it.
Create a HelmRepository source file:
yaml# clusters/my-cluster/sealed-secrets-source.yaml
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
name: sealed-secrets
namespace: flux-system
spec:
interval: 1h
url: https://bitnami-labs.github.io/sealed-secrets
Create a HelmRelease to install the controller:
yaml# clusters/my-cluster/sealed-secrets-release.yaml
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
name: sealed-secrets
namespace: flux-system
spec:
interval: 1h
chart:
spec:
chart: sealed-secrets
version: ">=1.15.0-0"
sourceRef:
kind: HelmRepository
name: sealed-secrets
namespace: flux-system
targetNamespace: kube-system
Commit and push both files to your Git repo:
bashgit add clusters/my-cluster/sealed-secrets-source.yaml
git add clusters/my-cluster/sealed-secrets-release.yaml
git commit -m "feat: add sealed secrets controller"
git push
Flux will detect the change and install the controller. Verify it's running:
bashkubectl get pods -n kube-system | grep sealed-secrets
sealed-secrets-controller-xxxxx 1/1 Running 0 1m
Step 2: Install the kubeseal CLI
kubeseal is the command-line tool you use to encrypt secrets before committing them.
macOS:
bashbrew install kubeseal
Linux:
bashKUBESEAL_VERSION=$(curl -s https://api.github.com/repos/bitnami-labs/sealed-secrets/tags \
| jq -r '.[0].name' | cut -c 2-)
curl -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 kubeseal
sudo install -m 755 kubeseal /usr/local/bin/kubeseal
Verify:
bashkubeseal --version
Step 3: Create and Seal Your First Secret
Start with a plain Kubernetes Secret. Don't commit this file. It's just an intermediate step.
yaml# secret-plain.yaml <-- DO NOT commit this file
apiVersion: v1
kind: Secret
metadata:
name: my-app-secret
namespace: default
type: Opaque
stringData:
database-password: "supersecret123"
api-key: "myapikey456"
Now seal it using kubeseal:
bashkubeseal \
--controller-name=sealed-secrets \
--controller-namespace=kube-system \
--format yaml \
< secret-plain.yaml \
sealed-secret.yaml
The output sealed-secret.yaml will look like this:
yamlapiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: my-app-secret
namespace: default
spec:
encryptedData:
database-password: AgBy8hW...long-encrypted-string...
api-key: AgCtr9X...another-long-encrypted-string...
template:
metadata:
name: my-app-secret
namespace: default
This is safe to commit. The encrypted strings are useless without the controller's private key.
bash# Delete the plain secret file — never commit it
rm secret-plain.yaml
Commit the sealed secret
git add sealed-secret.yaml
git commit -m "feat: add sealed app secret"
git push
Step 4: Verify the Secret Was Decrypted
Once Flux applies the SealedSecret to your cluster, the controller decrypts it and creates a regular Kubernetes Secret:
bash# Check the SealedSecret was applied
kubectl get sealedsecret my-app-secret -n default
Check the resulting Kubernetes Secret exists
kubectl get secret my-app-secret -n default
Verify the values (for debugging only — don't do this in production)
kubectl get secret my-app-secret -n default -o jsonpath='{.data.database-password}' | base64 --decode
Your app can now reference the secret normally:
yamlenv:
- name: DATABASE_PASSWORD valueFrom: secretKeyRef: name: my-app-secret key: database-password
Common Beginner Mistakes to Avoid
- Accidentally committing the plain Secret Add this to your .gitignore: -plain.yaml *-secret-plain.yaml secret-plain
- Sealing without specifying namespace Sealed Secrets are namespace-scoped by default. A secret sealed for namespace: default will not decrypt in namespace: production. Always set the correct namespace when sealing.
- Losing the controller's private key If you delete your cluster and lose the private key, you cannot decrypt your sealed secrets. Back it up: bashkubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key \ -o yaml > sealed-secrets-master-key-backup.yaml Store this backup somewhere secure and separate from Git — a password manager, a vault, or encrypted cold storage.
- Rotating secrets manually When you need to change a secret value, create a new plain secret, seal it again, and commit the updated SealedSecret. The controller handles the rest.
How This Fits Into Your GitOps Workflow
Here's the full picture:
Developer machine
│
├── Creates plain Secret (never committed)
├── Runs kubeseal → produces SealedSecret
└── Commits SealedSecret to Git
│
▼
Git Repository (safe — only encrypted values)
│
▼
Flux detects change → applies SealedSecret to cluster
│
▼
Sealed Secrets Controller decrypts → creates real Secret
│
▼
Your app reads the Secret normally
Your Git repo becomes the single source of truth for your entire cluster — including secrets — without ever exposing sensitive values.
Summary
Plain Kubernetes Secrets in Git are a security risk — base64 is not encryption
Sealed Secrets encrypts your secrets so only your cluster can decrypt them
Flux + Sealed Secrets gives you a fully GitOps-native secrets workflow
Back up your controller's private key — losing it means losing access to all your sealed secrets
What's Next?
Once you're comfortable with this setup, consider exploring:
Secret rotation strategies — how to update secrets without downtime
Flux alerts — get notified when a SealedSecret fails to decrypt
HashiCorp Vault + Flux — for teams that need centralized secret management across multiple clusters
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.