Sealed Secrets Was Fine Until It Wasn’t
You’ve been shipping Sealed Secrets in your k3s cluster for a year. It works. Developers re-encrypt secrets when they rotate. Your backups are safe because the encryption key lives in the cluster. Life is… okay.
Then your ops team asks: “What if we need to share a secret with our staging cluster without deploying new sealed-secrets keys?” Or: “Can we audit who accessed what secret and when?” Or worse: “Our security audit says we need centralized secret rotation.”
Sealed Secrets will look at you and shrug. It’s a simple lock-the-secret-in-git solution, nothing more. For a home lab, that’s enough. For anything touching production, or anything where compliance people show up with checklists, you need something that talks to a proper secret backend.
External Secrets Operator (ESO) plus Vault is that something. It’s the car you take to work instead of the forklift you’ve been driving.
ESO doesn’t care where secrets live. You can use Vault, AWS Secrets Manager, HashiCorp Cloud Platform, Azure Key Vault, or even a plain HTTP API. Vault is the obvious choice for a self-hosted setup because you can run it on the same cluster, or on a separate rig if you’re paranoid about keeping secrets away from app pods.
Here’s the k3s setup: running Vault, installing ESO, and pulling secrets from Vault into your cluster without ever checking them into git.
What We’re Building
- Vault runs in a pod on your cluster (or separate, doesn’t matter)
- External Secrets Operator watches for
ExternalSecretresources - You write YAML that says “pull
my-app-db-passwordfrom Vault into a KubernetesSecret” - ESO talks to Vault, fetches the secret, creates a Kubernetes Secret, boom, your app pod mounts it
- Vault audits every read; you can rotate passwords without redeploying
- Sealed Secrets stays in your cluster, but you’re not using it for new workloads
If you’ve got a home lab k3s cluster running and you’re tired of managing secrets manually, this is your move.
Install Vault (Standalone, In-Cluster)
First, we’ll run Vault as a single stateful pod with file storage. That skips HA, but for a home lab it’s fine. If you want HA later, move to Vault’s integrated Raft storage rather than Consul: Raft is what HashiCorp ships the official Helm chart with, and it needs no second cluster to babysit.
Two things before the YAML, because they are the traps that eat an afternoon:
- The
hashicorp/vaultimage defaults toCMD ["server", "-dev"]. If you do not override the args, you get dev mode: in-memory storage, auto-unseal, a KV v2 engine already mounted atsecret/, and every secret gone on pod restart. Your PVC and your config file are both silently ignored. Plenty of “why did my Vault empty itself” threads start here. - Vault tries to
mlockits memory. In a container without theIPC_LOCKcapability it fails to start. Setdisable_mlock = trueor grant the capability. Below does the former, which is what the official Helm chart does.
Versions used here: Vault 2.1 and External Secrets Operator 2.x. If HashiCorp’s BUSL licence is a problem for you, OpenBao is the MPL-licensed fork of Vault 1.14 and speaks the same API, so everything below works against it with the image swapped.
Vault Namespace & Storage
Create a namespace and persistent volume for Vault’s data:
apiVersion: v1kind: Namespacemetadata: name: vault
---apiVersion: v1kind: PersistentVolumeClaimmetadata: name: vault-data namespace: vaultspec: accessModes: - ReadWriteOnce resources: requests: storage: 10Gi storageClassName: local-pathApply that. k3s gives you the local-path storage class by default.
Vault Deployment
Note the serviceAccountName and the system:auth-delegator binding. Vault validates ServiceAccount tokens through the Kubernetes TokenReview API, and it cannot call that API without that ClusterRole. Leave it out and Kubernetes auth fails later with a permission error that points nowhere useful.
apiVersion: v1kind: ServiceAccountmetadata: name: vault namespace: vault
---apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRoleBindingmetadata: name: vault-auth-delegatorroleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: system:auth-delegatorsubjects:- kind: ServiceAccount name: vault namespace: vault
---apiVersion: apps/v1kind: Deploymentmetadata: name: vault namespace: vaultspec: replicas: 1 strategy: type: Recreate # one RWO volume, so never two pods at once selector: matchLabels: app: vault template: metadata: labels: app: vault spec: serviceAccountName: vault containers: - name: vault image: hashicorp/vault:2.1 args: ["server", "-config=/vault/config/vault.hcl"] # without this you get dev mode ports: - containerPort: 8200 name: http env: - name: VAULT_ADDR value: "http://127.0.0.1:8200" volumeMounts: - name: vault-data mountPath: /vault/data - name: vault-config mountPath: /vault/config volumes: - name: vault-data persistentVolumeClaim: claimName: vault-data - name: vault-config configMap: name: vault-config
---apiVersion: v1kind: ConfigMapmetadata: name: vault-config namespace: vaultdata: vault.hcl: | ui = true disable_mlock = true api_addr = "http://vault.vault.svc.cluster.local:8200" listener "tcp" { address = "0.0.0.0:8200" tls_disable = 1 } storage "file" { path = "/vault/data" }
---apiVersion: v1kind: Servicemetadata: name: vault namespace: vaultspec: ports: - port: 8200 targetPort: 8200 selector: app: vaulttls_disable = 1 is acceptable for cluster-internal traffic on a home lab and nowhere else. If Vault is reachable off-cluster, terminate TLS properly and point the ClusterSecretStore at https://.
Deploy it:
kubectl apply -f vault.yamlkubectl -n vault logs -f deployment/vaultWait for the pod to be ready. You’ll see something like “Vault server started!” in the logs.
Initialise and Unseal Vault
A real Vault server starts uninitialised and sealed. Dev mode auto-unseals and hands you a root token, which is exactly why we did not use dev mode.
Port-forward to Vault:
kubectl -n vault port-forward service/vault 8200:8200In another shell, initialise it. One key share with a threshold of one is a home lab shortcut. Use three-of-five if anyone else depends on this cluster:
export VAULT_ADDR=http://localhost:8200vault operator init -key-shares=1 -key-threshold=1That prints an unseal key and an initial root token exactly once. Put both somewhere off the cluster right now: a password manager, a printed sheet in a drawer, anywhere that survives the cluster dying. There is no recovery path if you lose them.
vault operator unseal <unseal-key-from-above>export VAULT_TOKEN=<initial-root-token>vault statusYou should see Sealed false and Version 2.1.x.
Vault re-seals on every restart. With file storage and no auto-unseal, a node reboot leaves Vault sealed and every ExternalSecret failing to refresh until you unseal by hand. That is the biggest running cost of self-hosted Vault. Live with it, or configure auto-unseal against a cloud KMS or a Transit engine on a second Vault.
Enable the KV Secret Engine
A freshly initialised Vault has no KV engine mounted. Enable one at secret/:
vault secrets enable -version=2 -path=secret kvIf that returns path is already in use at secret/, you are in dev mode. Go back and check the args: on the Deployment.
Create a test secret:
vault kv put secret/my-app/db-password username=appuser password="super-secret-db-pass"vault kv get secret/my-app/db-passwordGood. Vault is running and storing secrets on disk that survive a restart.
Install External Secrets Operator
ESO is a Kubernetes operator that watches for ExternalSecret resources and syncs them to Kubernetes Secrets.
Add the Helm Repository
helm repo add external-secrets https://charts.external-secrets.iohelm repo updateInstall ESO
helm install external-secrets \ external-secrets/external-secrets \ -n external-secrets-system \ --create-namespaceVerify it’s running:
kubectl -n external-secrets-system get podsYou should see external-secrets-webhook-* and external-secrets-* pods.
Create a Vault Auth Token for ESO
ESO needs credentials to talk to Vault. We’ll create a Kubernetes ServiceAccount and enable Vault’s Kubernetes auth method so that the ServiceAccount can authenticate to Vault.
Enable Kubernetes Auth in Vault
Run this inside the Vault pod. The config needs the pod’s own ServiceAccount token and CA certificate, and the @ syntax reads them as files, so the paths have to exist where the command runs. Running it from your laptop against a port-forward fails, because those paths are not on your laptop and $KUBERNETES_SERVICE_HOST is not set there either.
kubectl -n vault exec -it deployment/vault -- sh -c ' export VAULT_ADDR=http://127.0.0.1:8200 export VAULT_TOKEN=<your-root-token> vault auth enable kubernetes vault write auth/kubernetes/config \ kubernetes_host="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT" \ kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'token_reviewer_jwt is deliberately absent. When Vault runs in the cluster, it uses its own ServiceAccount token to call TokenReview, which is why the system:auth-delegator binding earlier matters. Passing a static JWT instead means pinning a token that Kubernetes will eventually rotate out from under you.
Create a Vault Policy for ESO
KV v2 splits reads and lists across two paths: data lives under secret/data/, and listing happens under secret/metadata/. A policy that grants list on secret/data/* grants nothing useful.
vault policy write eso-policy - <<EOFpath "secret/data/*" { capabilities = ["read"]}path "secret/metadata/*" { capabilities = ["read", "list"]}EOFCreate a Kubernetes Auth Role
The bound namespace is where the ESO ServiceAccount lives, not where your apps live. ESO authenticates once on behalf of every ExternalSecret in the cluster, so external-secrets-system is the only namespace that belongs here.
vault write auth/kubernetes/role/external-secrets-role \ bound_service_account_names=external-secrets \ bound_service_account_namespaces=external-secrets-system \ audience=vault \ policies=eso-policy \ ttl=1hRecent Vault warns on roles with no audience, and the ESO documentation reports that Vault 1.21 and later can reject them outright. Setting it on both sides costs nothing and removes the question. The matching audiences: field goes in the ClusterSecretStore below, and the two must be identical.
Create a ClusterSecretStore
A ClusterSecretStore tells ESO how to authenticate to Vault. It’s a cluster-wide resource that external secrets reference.
apiVersion: external-secrets.io/v1kind: ClusterSecretStoremetadata: name: vault-backendspec: provider: vault: server: "http://vault.vault.svc.cluster.local:8200" path: "secret" version: "v2" # match the KV engine you enabled auth: kubernetes: mountPath: "kubernetes" role: "external-secrets-role" serviceAccountRef: name: external-secrets namespace: external-secrets-system # required on a ClusterSecretStore audiences: - vault # must match audience= on the Vault roleThe serviceAccountRef points to the ESO ServiceAccount created by the Helm chart. ESO mints a short-lived token for that account through the TokenRequest API and presents it to Vault, which validates it via TokenReview. On a ClusterSecretStore the namespace field is mandatory: without it ESO has no idea which namespace to look in and the store never becomes ready.
Apply it:
kubectl apply -f cluster-secret-store.yamlCreate an ExternalSecret
Now the fun part. Write an ExternalSecret resource that pulls a secret from Vault and creates a Kubernetes Secret.
apiVersion: external-secrets.io/v1kind: ExternalSecretmetadata: name: app-db-secret namespace: defaultspec: refreshInterval: 1h secretStoreRef: name: vault-backend kind: ClusterSecretStore target: name: app-db-secret creationPolicy: Owner template: engineVersion: v2 data: username: "{{ .username }}" password: "{{ .password }}" database_url: "postgresql://{{ .username }}:{{ .password }}@postgres.default.svc.cluster.local:5432/mydb" data: - secretKey: username remoteRef: key: my-app/db-password property: username - secretKey: password remoteRef: key: my-app/db-password property: passwordThis says:
- Fetch the secret at
secret/my-app/db-passwordfrom Vault (themy-app/db-passwordkey) - Extract the
usernameandpasswordproperties - Create a Kubernetes Secret called
app-db-secretwith those values - Optionally, use the
templatesection to build derived fields (like a full DB connection string)
Apply it:
kubectl apply -f external-secret.yamlCheck that the Kubernetes Secret was created:
kubectl get secret app-db-secret -o yamlYou should see:
data: username: YXBwdXNlcg== # base64-encoded "appuser" password: c3VwZXItc2VjcmV0LWRiLXBhc3M= # base64-encoded password database_url: cG9zdGdyZXNxbDovL2FwcHVzZXI6c3VwZXItc2VjcmV0LWRiLXBhc3NAcG9zdGdyZXMuZGVmYXVsdC5zdmMuY2x1c3Rlci5sb2NhbDo1NDMyL215ZGI=If the Secret doesn’t exist, check the ExternalSecret status:
kubectl describe externalsecret app-db-secretLook for errors like “failed to authenticate” or “key not found”. Common issues:
- Vault is sealed (run
vault statusand check) - The Kubernetes auth role is misconfigured
- The secret path doesn’t exist in Vault
Using the Secret in a Pod
In your Deployment or Pod spec, mount the Secret as an environment variable or volume:
apiVersion: v1kind: Podmetadata: name: app-podspec: containers: - name: app image: myapp:latest env: - name: DB_USER valueFrom: secretKeyRef: name: app-db-secret key: username - name: DB_PASSWORD valueFrom: secretKeyRef: name: app-db-secret key: password - name: DATABASE_URL valueFrom: secretKeyRef: name: app-db-secret key: database_urlYour app reads these environment variables. No secrets in the Pod spec, no secrets in git.
Rotate Secrets Without Redeploying
Update the secret in Vault:
vault kv put secret/my-app/db-password username=appuser password="new-shiny-password"ESO checks for updates every hour (controlled by refreshInterval: 1h in the ExternalSecret). When it detects a change, it rewrites the Kubernetes Secret.
Rewriting the Secret does not restart your pods, and this is where people get burned. What happens next depends on how the app consumes it:
- Secret mounted as a volume: the kubelet refreshes the files in place, typically within a minute or two. An app that re-reads the file picks up the change on its own.
- Secret injected as
env:variables: environment variables are set once at container start. The pod keeps the old password until something restarts it. Forever, if nothing does.
For the env-var case, use Stakater Reloader: annotate the Deployment with reloader.stakater.com/auto: "true" and it performs a rolling restart whenever a referenced Secret or ConfigMap changes. Prometheus does not do this, whatever a search result told you. It scrapes metrics.
Vault Outside the Cluster (Optional)
If you want Vault on a separate machine or in a different cluster, just change the server: URL in your ClusterSecretStore:
vault: server: "https://vault.mycompany.com:8200"And use a different auth method if Kubernetes auth doesn’t make sense. Vault supports AppRole (agent credentials), JWT (OIDC), or plain token auth.
When to Use This vs Sealed Secrets
Use Sealed Secrets if:
- Your cluster is immutable and you trust its encryption key
- You want everything encrypted in git and never touch an external system
- Your team is small and rotation is rare
Use ESO + Vault if:
- You need centralized secret management across clusters
- Compliance or security audits require secret rotation and audit trails
- You want to share secrets between staging and production safely
- You’re running multiple environments and hand-managing secrets is getting tedious
For a home lab with one cluster, Sealed Secrets is probably still fine. But once you’re managing anything more complex, or once you want to stop checking secrets into git even in encrypted form, ESO plus Vault is the obvious upgrade. The cost is honest and worth stating: one more service to run, and a Vault that seals itself every time it restarts.
Troubleshooting Checklist
ExternalSecret stuck in “pending”:
- Check
kubectl describe externalsecret <name>for the error - Verify Vault is reachable:
kubectl -n vault logs deployment/vault - Verify the secret exists in Vault:
vault kv get secret/my-app/db-password - Check RBAC: is the ESO ServiceAccount allowed to query Kubernetes auth?
Vault sealed unexpectedly:
- Run
vault statusto check seal status - Unseal with the unseal keys (stored somewhere safe, right?)
- This happens on every pod restart with file storage. That is normal, not a bug
- To stop unsealing by hand, configure auto-unseal against a cloud KMS or a Transit engine on a second Vault
“Authentication failed” in ExternalSecret status:
- Verify the Kubernetes auth role exists:
vault list auth/kubernetes/role - Check that
bound_service_account_namesisexternal-secretsandbound_service_account_namespacesisexternal-secrets-system, not your app’s namespace - Check the
audienceon the Vault role matchesaudiences:in the ClusterSecretStore - Confirm the Vault ServiceAccount has the
system:auth-delegatorClusterRoleBinding. Without itTokenReviewreturns a 403 and Vault reports the token as invalid - Verify TLS (if Vault runs with TLS, make sure your ClusterSecretStore uses the correct CA cert)
Secret not updating when you change it in Vault:
refreshIntervalcontrols how often ESO checks, default is 1 hour- Manually trigger a sync:
kubectl annotate externalsecret <name> force-sync=$(date +%s) --overwrite - Restart the ESO controller:
kubectl rollout restart -n external-secrets-system deployment/external-secrets
Secrets out of git, auditable at every access, rotatable without redeployment. That’s the promise, and ESO plus Vault delivers it. Just budget for the unseal.
Common Questions
Does the hashicorp/vault container run in dev mode by default?
Yes. The image’s default command is server -dev, so a Deployment that sets no args: gets dev mode: in-memory storage, auto-unseal, a KV v2 engine already mounted at secret/, and every secret lost on restart. Pass args: ["server", "-config=/vault/config/vault.hcl"] to run a real server.
Why does Vault Kubernetes auth fail with a permission error?
Vault validates ServiceAccount tokens by calling the Kubernetes TokenReview API, and it needs the system:auth-delegator ClusterRole to do that. Bind that ClusterRole to Vault’s own ServiceAccount. This is the most common cause of “authentication failed” on an otherwise correct External Secrets Operator setup.
Do my pods pick up a rotated secret automatically?
Only if the Secret is mounted as a volume. The kubelet refreshes mounted Secret files in place within a minute or two. Secrets injected as env: variables are set once at container start and never change until the pod restarts. Use Stakater Reloader to restart deployments when a Secret changes.
Do I have to unseal Vault by hand after every restart?
Yes, unless you configure auto-unseal. A Vault using file or Raft storage seals itself on every restart, and every ExternalSecret stops refreshing until you unseal it. Auto-unseal against a cloud KMS or a Transit engine on a second Vault removes the manual step.
Can I use OpenBao instead of Vault?
Yes. OpenBao is the MPL-licensed fork of Vault 1.14 and keeps the same API, CLI and Kubernetes auth method, so every command and manifest here works with the image swapped. Pick OpenBao if HashiCorp’s BUSL licence is a problem, and Vault if you want the newer enterprise-adjacent features.