A base64-encoded string is not encryption. That single fact trips up more Kubernetes operators than almost anything else in the platform, and it’s why “kubernetes secrets” remains one of the most searched cluster-security terms heading into the back half of 2026. Kubernetes stores native Secret objects in etcd with reversible encoding by default, not real cryptographic protection, and teams that treat a kubectl get secret -o yaml output as “safe” are one misconfigured RBAC rule away from a full credential dump.
This tutorial walks through building a production-grade secrets pipeline on a current Kubernetes 1.34 cluster: encryption at rest with a KMS provider, the External Secrets Operator (ESO) syncing values from a cloud vault, Sealed Secrets for GitOps-safe encrypted manifests, and the Secrets Store CSI Driver for mounting cloud-managed secrets directly into pods without ever writing them to the Kubernetes API. By the end you’ll have a working project you can adapt to AWS, Azure, or GCP.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why plain Kubernetes Secrets aren’t enough in 2026
Kubernetes 1.34 is the current stable minor release as of September 2026, with the latest patch sitting at 1.34.11, released August 11, 2026, according to the official Kubernetes patch release page. Upstream 1.34 entered maintenance mode on August 27, 2026, and hits end-of-life on October 27, 2026 — a detail that matters for secrets management because Go runtime CVEs have repeatedly forced out-of-band patch releases this year. Kubernetes 1.34.5, for example, shipped purely to pick up a new Go build addressing multiple Go CVEs, with no other code changes. If your cluster’s dependency chain is stale, your secrets encryption path inherits that risk even when your own manifests look fine.
Native Kubernetes Secret objects have three structural weaknesses worth naming up front. First, they’re base64-encoded, not encrypted, unless you explicitly configure an EncryptionConfiguration resource with a real provider. Second, anyone with get or list RBAC permission on secrets in a namespace can read every value in plaintext — Kubernetes RBAC has no field-level restriction inside a Secret object. Third, Secrets committed to Git for GitOps workflows are a liability by default, since a plain Secret manifest is just base64 text sitting in your repo history forever, even after you delete and rotate it.
The fix isn’t a single tool. It’s a layered approach: encrypt secrets at rest inside etcd, keep sensitive values out of Git entirely (or encrypt what does land in Git), and prefer pulling secrets from an external manager at runtime over storing static copies in the cluster. That’s the stack this tutorial builds.
How managed Kubernetes providers handle secrets differently
The steps in this tutorial apply whether you run self-managed control planes or a managed offering, but the starting point differs enough to call out before you dive in. Amazon EKS, Google’s GKE, and Azure’s AKS all expose encryption at rest as a cluster-level setting tied to their own KMS product (AWS KMS, Cloud KMS, Azure Key Vault respectively), which means you skip hand-writing an EncryptionConfiguration file and instead flip a flag during cluster creation or an update call. GKE’s release notes track exactly which Kubernetes 1.34.x patch each release channel ships, which is worth checking before you assume your managed cluster is running the same patch level as a self-managed one.
The practical difference shows up in two places. First, managed control planes handle etcd storage and backup themselves, so the “did my old secrets get re-encrypted” question in Step 2 is partly the vendor’s responsibility once you turn on the setting — though you still need to force a rewrite of existing objects yourself in most cases. Second, IAM integration for External Secrets Operator and the CSI driver is cloud-specific: EKS uses IRSA or EKS Pod Identity, AKS uses Workload Identity, and GKE uses Workload Identity Federation. The SecretStore and SecretProviderClass manifests in this tutorial use AWS syntax, but the underlying pattern — a service account mapped to a scoped cloud identity — carries over directly to the other two clouds with different field names.
Prerequisites and versions
Confirm each of these before starting. Version mismatches are the number one cause of broken secrets pipelines, since External Secrets Operator ties its releases tightly to specific Kubernetes minor versions.
| Tool | Minimum version | Notes |
|---|---|---|
| Kubernetes | 1.34.x (current stable) | 1.34 entered maintenance mode Aug. 27, 2026; upgrade before Oct. 27, 2026 EOL |
| kubectl | v1.34.x | Match client to server minor version |
| Helm | v3.15+ | Used to install ESO, Sealed Secrets, and the CSI driver |
| External Secrets Operator | 2.7 (Helm chart 2.10.0) | ESO 2.7 released June 26, 2026, supports Kubernetes 1.34–1.35 |
| Sealed Secrets controller | latest Bitnami/VMware Tanzu build | Encrypts manifests so they’re safe to commit to Git |
| Secrets Store CSI Driver | v1.5.4 | Released Oct. 1, 2025; mounts cloud secrets as volumes |
| Cloud CLI (aws/az/gcloud) | current stable | Needed to create the backing secret store |
You’ll also need cluster-admin access to install operators and CRDs, and an account on at least one cloud secrets backend — AWS Secrets Manager, Azure Key Vault, GCP Secret Manager, or a self-hosted HashiCorp Vault instance. This tutorial uses AWS Secrets Manager for the primary walkthrough and notes the Azure/GCP equivalents where they diverge.
Step 1: Audit what’s already sitting in your cluster
Before adding tooling, find out how exposed you already are. Run this across every namespace to see how many raw Secret objects exist and who can read them.
kubectl get secrets --all-namespaces -o json | \
jq -r '.items[] | select(.type=="Opaque") | "\(.metadata.namespace)/\(.metadata.name)"'
# Check who can read secrets in a given namespace
kubectl auth can-i get secrets --namespace production --list-all-namespaces=false
kubectl get rolebindings,clusterrolebindings -o json | \
jq -r '.items[] | select(.roleRef.name | test("secret"; "i")) | .metadata.name'
In most clusters this turns up more Opaque secrets than expected — leftover database credentials from decommissioned services, API keys pasted in during a demo, TLS certs that should have rotated months ago. Note the count. You’ll use it later to measure how much you’ve reduced direct etcd exposure.
Cross-reference that list against your deployments to find orphaned secrets — objects with no pod, deployment, or CronJob still referencing them. These are the easiest wins in the whole project, since deleting an unused Secret removes exposure with zero risk of breaking a running workload.
# Find secrets not referenced by any pod's env, envFrom, or volume
kubectl get secrets -n production -o json | jq -r '.items[].metadata.name' > all-secrets.txt
kubectl get pods -n production -o json | \
jq -r '.items[].spec.containers[].envFrom[]?.secretRef.name, .items[].spec.volumes[]?.secret.secretName' \
| sort -u > referenced-secrets.txt
comm -23 <(sort all-secrets.txt) referenced-secrets.txt
Anything printed by that last command is a candidate for deletion, but confirm it's not referenced by an Ingress TLS block, an admission webhook, or a service account's imagePullSecrets before removing it — those don't show up in the pod-spec check above.
Step 2: Turn on encryption at rest
Kubernetes 1.34 still handles encryption at rest through an EncryptionConfiguration resource passed to the API server, supporting providers like aescbc, secretbox, and cloud KMS plugins, as documented in the official KMS provider guide. If you're running self-managed control planes, this step is on you — managed services like EKS, AKS, and GKE offer KMS-backed encryption as a cluster-level setting instead.
# /etc/kubernetes/enc/encryption-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- kms:
apiVersion: v2
name: myKmsPlugin
endpoint: unix:///var/run/kmsplugin/socket.sock
- identity: {}
Then point the API server at it with --encryption-provider-config=/etc/kubernetes/enc/encryption-config.yaml and restart the control plane components. On EKS, enable it instead with:
aws eks associate-encryption-config \
--cluster-name my-cluster \
--encryption-config '[{"resources":["secrets"],"provider":{"keyArn":"arn:aws:kms:us-east-1:123456789012:key/abcd-1234"}}]'
This step only encrypts new writes and updates to existing secrets — it does not retroactively re-encrypt everything already in etcd. Run kubectl get secrets --all-namespaces -o json | kubectl replace -f - afterward to force a rewrite of every existing Secret through the new encryption path.
Step 3: Install the External Secrets Operator
ESO is the piece that actually changes your workflow day to day. Instead of a developer running kubectl create secret with a plaintext value, ESO watches a SecretStore or ExternalSecret custom resource, pulls the real value from your cloud vault, and syncs it into a native Kubernetes Secret that your pods can still consume normally. The secret value itself never has to be typed into a YAML file or pasted into a Git commit. The project's own documentation site maintains a version-by-version stability matrix that's worth bookmarking before you pin a chart version in production.
helm repo add external-secrets https://charts.external-secrets.io
helm repo update
helm install external-secrets external-secrets/external-secrets \
--namespace external-secrets \
--create-namespace \
--version 2.10.0
Verify the operator and its CRDs landed correctly:
kubectl get pods -n external-secrets
kubectl get crds | grep external-secrets.io
Expected output for the pods command:
NAME READY STATUS RESTARTS AGE
external-secrets-7d8f9c5b4-x2k9p 1/1 Running 0 45s
external-secrets-cert-controller-6b9d8f7c5-p3n2q 1/1 Running 0 45s
external-secrets-webhook-5c7b8d9f4-m8k2r 1/1 Running 0 45s
ESO ships frequent, short-lived point releases — version 2.0 through 2.7 all target Kubernetes 1.34–1.35, with each minor version carrying its own end-of-life date roughly a month after release. Pin your Helm chart version explicitly in production and track the ESO stability matrix rather than letting helm upgrade pull whatever is newest.
Step 4: Connect ESO to AWS Secrets Manager
With ESO running, create a SecretStore that tells it where to pull values from. This example uses IAM role-based auth via IRSA (IAM Roles for Service Accounts), which avoids putting long-lived AWS credentials anywhere in the cluster.
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: aws-secrets-manager
namespace: production
spec:
provider:
aws:
service: SecretsManager
region: us-east-1
auth:
jwt:
serviceAccountRef:
name: eso-aws-sa
Now define an ExternalSecret that maps a value from AWS Secrets Manager into a real Kubernetes Secret your app can mount:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: db-credentials
namespace: production
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: SecretStore
target:
name: db-credentials-synced
creationPolicy: Owner
data:
- secretKey: password
remoteRef:
key: prod/database
property: password
- secretKey: username
remoteRef:
key: prod/database
property: username
Apply it and check that the synced secret appeared:
kubectl apply -f external-secret.yaml
kubectl get externalsecret db-credentials -n production
kubectl get secret db-credentials-synced -n production
The refreshInterval: 1h field is what makes rotation practical. When the value changes in AWS Secrets Manager, ESO picks it up on the next poll and updates the Kubernetes Secret automatically — no manual kubectl commands, no redeploy, though your application still needs to watch the mounted file or restart to pick up the new value depending on how it reads secrets.
Step 5: Add Sealed Secrets for what has to live in Git
ESO handles secrets that live in a cloud vault, but some teams still want a GitOps-native option for smaller clusters or air-gapped environments without a cloud backend. Sealed Secrets, maintained by Bitnami/VMware Tanzu and hosted on GitHub, solves a narrower problem: it lets you encrypt a Secret client-side into a SealedSecret object that's genuinely safe to commit to a public or private Git repo, because only the in-cluster controller holds the private key to decrypt it.
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets \
--namespace kube-system
# Install the matching kubeseal CLI (macOS example)
brew install kubeseal
Seal an existing plaintext secret file so it's safe to commit:
kubectl create secret generic api-key \
--from-literal=key=super-secret-value \
--dry-run=client -o yaml > api-key-secret.yaml
kubeseal --format yaml < api-key-secret.yaml > api-key-sealedsecret.yaml
# api-key-sealedsecret.yaml is now safe to git add / git commit
rm api-key-secret.yaml
Apply the sealed version and the controller decrypts it into a normal Secret inside the cluster:
kubectl apply -f api-key-sealedsecret.yaml
kubectl get secret api-key
The key operational gotcha: back up the Sealed Secrets controller's private key immediately after installing it. If the controller pod is deleted and the key isn't backed up, every SealedSecret you've committed to Git becomes permanently undecryptable, and you'll have to regenerate and re-seal everything from scratch.
kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key \
-o yaml > sealed-secrets-key-backup.yaml
# Store this file somewhere outside the cluster — a password manager or offline vault
Step 6: Mount secrets directly with the Secrets Store CSI Driver
ESO and Sealed Secrets both still write a copy into the Kubernetes API as a Secret object. The Secrets Store CSI Driver, currently at v1.5.4, takes a different approach: it mounts secrets from AWS Secrets Manager, Azure Key Vault, or GCP Secret Manager directly into a pod's filesystem as a volume, with no Kubernetes Secret object created at all unless you explicitly opt into syncing one.
helm repo add secrets-store-csi-driver https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts
helm install csi-secrets-store secrets-store-csi-driver/secrets-store-csi-driver \
--namespace kube-system \
--set syncSecret.enabled=true
# Install the AWS provider for the driver
kubectl apply -f https://raw.githubusercontent.com/aws/secrets-store-csi-driver-provider-aws/main/deployment/aws-provider-installer.yaml
Define a SecretProviderClass describing what to fetch, then reference it as a volume in your pod spec:
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: prod-app-secrets
namespace: production
spec:
provider: aws
parameters:
objects: |
- objectName: "prod/api-token"
objectType: "secretsmanager"
---
apiVersion: v1
kind: Pod
metadata:
name: app-pod
namespace: production
spec:
containers:
- name: app
image: my-app:latest
volumeMounts:
- name: secrets-store
mountPath: "/mnt/secrets"
readOnly: true
volumes:
- name: secrets-store
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: "prod-app-secrets"
This pattern is the strongest option for regulated workloads where auditors specifically ask "does the secret value ever touch etcd." With syncSecret.enabled=false, the answer is no — the value only exists in the pod's tmpfs mount for as long as the pod runs.
Step 7: Wire up HashiCorp Vault for centralized secrets
If you're managing secrets across Kubernetes and non-Kubernetes workloads — VMs, CI pipelines, third-party integrations — HashiCorp Vault remains the standard centralized option in 2026. Vault integrates with Kubernetes through its Kubernetes auth method, letting pods authenticate using their service account token instead of a static Vault token.
# Enable the Kubernetes auth method in Vault
vault auth enable kubernetes
vault write auth/kubernetes/config \
kubernetes_host="https://$KUBERNETES_SERVICE_HOST:443" \
[email protected]
# Create a policy scoped to one app's secrets
vault policy write app-policy - <
Pods can then either use Vault Agent as a sidecar to auto-fetch and refresh secrets to a shared volume, or you can point ESO's SecretStore at Vault instead of AWS Secrets Manager, reusing the same ExternalSecret pattern from Step 4. The Vault route makes sense when you already run Vault for non-Kubernetes systems and want one control plane instead of three separate cloud-native tools. HashiCorp's own Vault documentation covers the full set of auth methods and secret engines beyond what this tutorial's Kubernetes-focused slice touches.
One tradeoff worth naming: adding Vault to a stack that already runs ESO against a cloud secrets manager means you now have two systems capable of injecting secrets into the same cluster. Pick one as the source of truth per application rather than letting both manage the same ExternalSecret — otherwise you end up debugging which system last wrote a given value when a credential mysteriously changes.
Step 7b: Weigh the operational cost of each option
Every layer added to a secrets pipeline is also a layer someone has to operate at 2 a.m. when it breaks. Running your own HashiCorp Vault cluster means owning unseal procedures, storage backend backups, and a second set of upgrade cycles independent of Kubernetes itself — a real cost that's easy to underestimate when a proof-of-concept Vault instance looks simple. Cloud-native secrets managers paired with ESO shift that operational burden to the cloud provider, at the cost of vendor lock-in and, for high-volume secret access patterns, per-API-call pricing that can add up in AWS Secrets Manager or Azure Key Vault if you're not caching or batching reads.
Sealed Secrets carries the lowest infrastructure footprint of the four approaches covered here — no external vault, no per-call billing, just a controller and a keypair — but it also offers the weakest audit trail, since there's no central log of who accessed which secret value and when the way a cloud secrets manager or Vault provides natively. Match the approach to what your compliance requirements actually demand rather than defaulting to the most feature-complete option for every workload; a small internal tool with no regulatory scope doesn't need the same pipeline as a payments service.
Step 8: Lock down RBAC around secrets
None of the tooling above matters if a broad ClusterRole still grants blanket read access to secrets across every namespace. Scope access down to exactly what each service account needs.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: production
name: secret-reader-app-only
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["db-credentials-synced"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: app-secret-binding
namespace: production
subjects:
- kind: ServiceAccount
name: app-sa
namespace: production
roleRef:
kind: Role
name: secret-reader-app-only
apiGroup: rbac.authorization.k8s.io
The resourceNames field is the part most teams skip. Without it, a Role granting get on secrets applies to every secret in the namespace, not just the one the service is supposed to read. Naming the specific resource closes that gap.
Step 9: Rotate secrets without downtime
A secrets pipeline that can't rotate values without a deployment is only half finished. With ESO's refreshInterval already polling the backing store, rotation becomes a two-part problem: getting the new value into the Kubernetes Secret (ESO handles this), and getting your running pods to actually use it.
# Trigger an immediate ESO refresh instead of waiting for the interval
kubectl annotate externalsecret db-credentials -n production \
force-sync=$(date +%s) --overwrite
# For apps that only read env vars at startup, roll the deployment
# after confirming the secret updated
kubectl rollout restart deployment/app -n production
Apps that mount secrets as a volume rather than an environment variable pick up updates automatically within about a minute (the kubelet sync period), no restart required — one more reason to prefer volume mounts over env vars for anything that rotates on a schedule.
Step 10: Add CI scanning so secrets never reach Git in the first place
Every layer above assumes a developer doesn't accidentally paste a raw credential into a commit. That assumption fails often enough that secret-scanning in CI is now standard practice. Run a scanner like Gitleaks against every pull request before it merges.
# .github/workflows/secrets-scan.yml
name: secrets-scan
on: [pull_request]
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
If your team hasn't set this up yet, the full walkthrough is in our Gitleaks setup guide, which covers pre-commit hooks in addition to the CI gate shown here.
Step 11: Scan container images for baked-in secrets
Secrets don't only leak through Git — they also end up baked into container image layers when a Dockerfile COPYs a .env file or a build arg gets cached into an intermediate layer. Run an image scanner as part of your build pipeline to catch this before the image ships.
trivy image --severity HIGH,CRITICAL --scanners secret my-app:latest
Our Trivy container scanning guide covers setting this up as a CI gate that blocks builds on HIGH or CRITICAL findings — a practice now baked into most 2026 Kubernetes project checklists alongside probes and etcd backups.
Step 12: Verify the full pipeline end to end
Run through this checklist once everything is deployed to confirm the pipeline actually behaves the way you designed it.
# 1. Confirm encryption at rest is active
kubectl get secrets --all-namespaces -o json | \
kubectl -n kube-system exec -i etcd-pod -- etcdctl get /registry/secrets/production/db-credentials-synced
# 2. Confirm ESO is syncing (status should show SecretSynced=True)
kubectl get externalsecret -A -o wide
# 3. Confirm no plaintext secrets in Git history
gitleaks detect --source . --verbose
# 4. Confirm RBAC scoping
kubectl auth can-i get secrets --as=system:serviceaccount:production:other-app -n production
# 5. Confirm CSI-mounted secrets never synced to etcd (if syncSecret disabled)
kubectl get secrets -n production | grep prod-app-secrets # should return nothing
If step 1 returns encrypted binary garbage instead of readable base64, encryption at rest is working. If step 4 returns "no," your RBAC scoping held. Both should be true before you call this done.
Testing the pipeline against a simulated compromise
A verification checklist confirms the pipeline is configured correctly. It doesn't confirm the pipeline actually reduces damage when something goes wrong. Run a tabletop exercise before you trust this in production: pick one service account, simulate it being compromised, and walk through exactly what an attacker with that identity could reach.
# Impersonate a specific service account and see what it can actually read
kubectl auth can-i --list --as=system:serviceaccount:production:app-sa -n production
# Check if that service account's token could read secrets in OTHER namespaces
kubectl auth can-i get secrets --as=system:serviceaccount:production:app-sa -n staging
kubectl auth can-i get secrets --as=system:serviceaccount:production:app-sa -n kube-system
If the answer to either cross-namespace check comes back "yes," the RBAC scoping from Step 8 has a gap somewhere — most often a ClusterRoleBinding left over from an earlier, less careful setup. Before this project, a compromised pod's service account token in a poorly scoped cluster can often read every Secret in every namespace. After the RBAC tightening in Step 8, that same token should be limited to the handful of specifically named secrets its own workload actually uses. That before-and-after comparison is the real measure of whether this tutorial's changes did anything, not just whether the YAML applied without errors.
Common pitfalls
These are the mistakes that show up most often when teams build this stack for the first time.
- Forgetting to back up the Sealed Secrets private key. Losing it means every committed SealedSecret is permanently unreadable — there is no recovery path.
- Running encryption-at-rest migration only on new writes. Old secrets stay in plaintext-equivalent form in etcd until you force a rewrite with
kubectl replace. - Mismatching ESO and Kubernetes versions. ESO's short release cycle means a chart pinned six months ago may no longer be supported on your current cluster minor version.
- Skipping
resourceNamesin RBAC Roles. A Role scoped to the "secrets" resource without naming specific objects grants access to every secret in the namespace. - Treating
refreshIntervalas instant. ESO polls on an interval, not a push — a compromised credential you rotate upstream is still live in the cluster until the next poll or a forced sync. - Storing the Vault unseal keys or root token in the same cluster they protect. That defeats the separation of duties Vault is meant to provide.
- Assuming CSI-mounted secrets are automatically excluded from etcd. They only stay out of etcd if
syncSecret.enabledis left off — many tutorials enable it by default for convenience, quietly reintroducing the exposure you were trying to avoid.
Troubleshooting
Working through these eight issues covers most of what breaks in a fresh secrets pipeline setup.
- ExternalSecret stuck in "SecretSyncedError." Run
kubectl describe externalsecret <name> -n <namespace>and check the events. This is almost always an IAM permissions issue — the service account role can't read the specific secret path in AWS Secrets Manager. - kubeseal fails with "no key could decrypt secret." The Sealed Secrets controller was reinstalled or its key rotated after the SealedSecret was created. Re-seal the value against the current controller key.
- CSI driver pod CrashLoopBackOff. Check that the correct cloud provider plugin (aws/azure/gcp) is installed alongside the base driver — the base Helm chart doesn't include any cloud provider by default.
- Secret value doesn't update in a running pod after rotation. Env-var-based secrets never update without a pod restart. Switch to volume mounts if you need live rotation.
- "Error from server (Forbidden)" when ESO tries to write the target Secret. The ESO service account itself needs RBAC permission to create/update Secrets in the target namespace — check its ClusterRole bindings.
- Vault Kubernetes auth returns "permission denied." The bound service account name or namespace in the Vault role doesn't match the pod's actual service account exactly — this is case-sensitive and namespace-scoped.
- Gitleaks flags false positives on test fixtures. Add a
.gitleaksignorefile with the specific fingerprint hashes for known-safe test values rather than disabling the rule entirely. - Encryption at rest configured but secrets still readable as plain base64 via etcdctl. The API server wasn't restarted after the
EncryptionConfigurationchange, or the flag path is wrong — checkkube-apiserverpod logs for a startup error referencing the encryption config file.
Comparing the four approaches
No single tool covers every case. Here's how the four pieces of this tutorial's stack actually differ in practice.
| Approach | Where the value lives | Best for | Rotation model |
|---|---|---|---|
| Native K8s Secret + encryption at rest | etcd (encrypted) | Baseline hygiene on every cluster | Manual, via kubectl |
| External Secrets Operator | Cloud vault, synced to etcd | Teams already using AWS/Azure/GCP secret managers | Automatic on refreshInterval poll |
| Sealed Secrets | Encrypted in Git, decrypted in-cluster | GitOps workflows, air-gapped or no cloud vault | Manual re-seal and commit |
| Secrets Store CSI Driver | Cloud vault, mounted as volume (etcd optional) | Regulated workloads that must avoid etcd exposure | Automatic, near-real-time via volume refresh |
Advanced tips for scaling this beyond one cluster
Once the base pipeline works on one cluster, a few practices make it hold up across a fleet. First, use ESO's ClusterSecretStore resource instead of a namespaced SecretStore when the same backing vault serves multiple namespaces or teams — it avoids duplicating the same store definition dozens of times. Second, if you're running multiple clusters against the same cloud account, scope IAM roles per-cluster rather than sharing one broad role, so a compromised cluster can't read secrets belonging to another. Third, combine the Secrets Store CSI Driver's rotationPollInterval setting with a readiness probe that fails if a mounted secret file is older than your rotation SLA — this turns "the secret didn't rotate" from a silent failure into a visible one in your monitoring dashboards. Fourth, if your organization is also standardizing internal developer workflows, a self-service portal like the one covered in our Backstage developer portal setup guide can expose "request a secret" as a templated action instead of a Slack message to the platform team, which cuts down on secrets getting passed around outside the pipeline entirely.
Finally, treat the RBAC hardening in Step 8 as an ongoing audit, not a one-time setup. New services get added, old ones get decommissioned, and stale RoleBindings granting broad secret access tend to survive long after the workload they were built for is gone. Our CIS Benchmarks hardening guide includes a section on periodic RBAC review that pairs well with the checklist in Step 12 above. Teams running Kubernetes autoscaling alongside this pipeline should also check our Karpenter for EKS Autoscaling guide, since new nodes spinning up need the same IRSA and CSI driver configuration as existing ones or secret mounts will silently fail on fresh capacity.
The complete working project
Here's every piece from this tutorial assembled into a single deployable set of manifests, structured the way you'd lay it out in a real GitOps repo.
k8s-secrets-project/
├── 00-encryption/
│ └── encryption-config.yaml
├── 01-eso/
│ ├── helm-values.yaml
│ ├── secretstore-aws.yaml
│ └── externalsecret-db.yaml
├── 02-sealed-secrets/
│ ├── api-key-sealedsecret.yaml
│ └── key-backup.yaml # stored outside Git, gitignored
├── 03-csi-driver/
│ ├── secretproviderclass.yaml
│ └── app-pod.yaml
├── 04-vault/
│ ├── vault-policy.hcl
│ └── vault-role.yaml
├── 05-rbac/
│ ├── role-secret-reader.yaml
│ └── rolebinding-app.yaml
├── 06-ci/
│ ├── secrets-scan.yml # Gitleaks GitHub Action
│ └── image-scan.yml # Trivy secret scanner
└── README.md
Deploy the whole stack in order:
kubectl apply -f 01-eso/secretstore-aws.yaml
kubectl apply -f 01-eso/externalsecret-db.yaml
kubectl apply -f 02-sealed-secrets/api-key-sealedsecret.yaml
kubectl apply -f 03-csi-driver/secretproviderclass.yaml
kubectl apply -f 03-csi-driver/app-pod.yaml
kubectl apply -f 05-rbac/role-secret-reader.yaml
kubectl apply -f 05-rbac/rolebinding-app.yaml
# Confirm everything synced
kubectl get externalsecret,sealedsecret,secretproviderclass -A
This layout keeps each concern isolated — encryption config, ESO sync rules, GitOps-safe sealed values, CSI mounts, Vault policy, RBAC, and CI scanning all live in their own directory, so a change to one doesn't require touching the others.
Frequently asked questions
Are Kubernetes Secrets encrypted by default?
No. Native Secret objects are base64-encoded, which is trivially reversible, not encrypted. You must explicitly configure an EncryptionConfiguration with a real provider (KMS, aescbc, secretbox) to get actual encryption at rest in etcd.
Do I need External Secrets Operator, Sealed Secrets, and the CSI driver all at once?
No. Most teams pick one primary approach based on their setup — ESO if you already use a cloud secrets manager, Sealed Secrets if you need GitOps-safe manifests without a cloud vault, and the CSI driver if you need secrets to never touch etcd at all. They can coexist, but a single cluster rarely needs all three running for the same workloads.
What Kubernetes version does External Secrets Operator 2.7 support?
ESO 2.7, released June 26, 2026, supports Kubernetes 1.34 and 1.35. Earlier 2.x releases (2.0 through 2.6) also cover the 1.34–1.35 range, but each carries its own short end-of-life window, so pin your version and track ESO's stability matrix rather than assuming backward compatibility indefinitely.
Is HashiCorp Vault still necessary if I'm using a cloud-native secrets manager?
Only if you have workloads outside Kubernetes — VMs, CI runners, third-party services — that also need centralized secret access. If everything you run lives inside one cloud's Kubernetes clusters, the native cloud secrets manager (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager) paired with ESO is usually simpler to operate than running Vault yourself.
What happens if I lose the Sealed Secrets controller's private key?
Every SealedSecret ever encrypted against that key becomes permanently undecryptable. There's no recovery mechanism — this is by design, since the whole point is that only the controller holding that key can decrypt the values. Back the key up to secure storage outside the cluster immediately after installation.
Can I mount a secret from AWS Secrets Manager without ever creating a Kubernetes Secret object?
Yes, using the Secrets Store CSI Driver with syncSecret.enabled=false. The value is mounted directly into the pod's filesystem as a volume and never written to the Kubernetes API or etcd, which satisfies stricter compliance requirements around secret exposure.
How often should production secrets be rotated?
There's no universal number, but most compliance frameworks referenced in 2026 platform security guides recommend 90 days for standard credentials and far shorter windows — hours to days — for anything tied to a security incident or a departing employee. The automation in Step 9 is what makes short rotation windows operationally realistic instead of a manual burden.
Why does my ExternalSecret show "SecretSynced" but the pod still uses an old value?
If your application reads the secret as an environment variable, Kubernetes does not automatically update environment variables in a running container after the underlying Secret changes — only a new pod (via rollout or restart) picks up the new value. Volume-mounted secrets update in place within about a minute due to the kubelet's sync period.


