Intro
The old repo had a Pi-hole admin password and a Tailscale OAuth client secret committed in plain text. For the rebuild, the rule was simple: no credential in git, ever. That needs somewhere else to keep them.
TL;DR: OpenBao 2.7.1 as a 3-node Raft cluster with static auto-unseal, External Secrets Operator to turn OpenBao entries into Kubernetes Secrets, and the agent injector for workloads that should never have a Secret object. Every secret enters through stdin, never through arguments or the terminal.
The Figure
The vault is OpenBao. The three lock stations on its roof are the Raft
members, one per Proxmox host, and the time lock beside them is the static
seal: press 1, 2 or 3 to restart a member and watch it unseal itself from
the key box, with nobody typing a key share. E sends the courier lift
(External Secrets Operator) to fetch a sealed envelope and file it in the rack
of Kubernetes Secrets. I fires the pneumatic tube (the agent injector), which
writes into the pod and never touches the rack. I generated the figure with the
anatomy agent skill.
The Situation
- OpenBao chart 0.30.3 (OpenBao 2.7.1)
- External Secrets Operator 2.12.0
- local-path-provisioner v0.0.37 as the StorageClass
- No Longhorn (I had removed it). Raft replicates OpenBao’s data itself, so node-local disks are enough.
Setup
OpenBao on Raft
Three server pods, one per Proxmox host, joined through retry_join:
storage "raft" {
path = "/openbao/data"
retry_join { leader_api_addr = "https://openbao-0.openbao-internal:8200" }
retry_join { leader_api_addr = "https://openbao-1.openbao-internal:8200" }
retry_join { leader_api_addr = "https://openbao-2.openbao-internal:8200" }
}
The StatefulSet is OrderedReady, so openbao-1 and openbao-2 start only
after openbao-0 is initialized and unsealed.
Static auto-unseal, and its trade-off
I didn’t want to type three key shares after every pod restart, and I had no KMS handy. OpenBao’s static seal reads a 32-byte key from a file:
seal "static" {
current_key_id = "lab-2026-10-12"
current_key = "file:///openbao/userconfig/openbao-seal-key/key"
}
The key lives in a Kubernetes Secret mounted into the pods, with a backup copy outside the cluster. The trade-off is explicit: anyone with cluster-admin can read the unseal key. For a single-person homelab that’s acceptable; for anything shared it isn’t.
With auto-unseal, bao operator init returns recovery keys instead of unseal
keys. I wrote the output straight to a mode-600 file instead of the terminal:
(umask 077; kubectl -n openbao exec openbao-0 -- \
bao operator init -recovery-shares=5 -recovery-threshold=3 -format=json > init.json)
Secrets go in through stdin
Every write to OpenBao follows the same pattern: the token and the value travel through stdin, so they never appear in process arguments, shell history or the terminal:
{ jq -r .root_token init.json; printf %s "$VALUE"; } \
| kubectl -n openbao exec -i openbao-0 -- sh -c \
'read -r BAO_TOKEN; export BAO_TOKEN; bao kv put -mount=secret my-app/db password=-'
External Secrets Operator
ESO authenticates to OpenBao with Kubernetes auth and a read-only policy, and apps declare what they need:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: pihole-admin
spec:
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: pihole-admin
data:
- secretKey: password
remoteRef:
key: pihole/admin
property: password
Today this feeds the Pi-hole password, the Cloudflare token for cert-manager and external-dns, and the Cloudflare Tunnel token.
Issue 1: The Injector Ignored My Pod
Symptom
A test pod with openbao.hashicorp.com/agent-inject: "true" started without any
sidecar.
Cause
The chart’s injector image is still hashicorp/vault-k8s, which watches the
vault.hashicorp.com/ annotation prefix. The injected agent itself is
OpenBao (quay.io/openbao/openbao).
Fix
Use the vault.hashicorp.com/ annotations. The secret landed in
/vault/secrets/greeting as expected.
Issue 2: Helm Wouldn’t Upgrade the Chart
Symptom
UPGRADE FAILED: conflict occurred while applying object /openbao-agent-injector-cfg ...
conflict with "vault-k8s": .webhooks[name="vault.hashicorp.com"].clientConfig.caBundle
Cause
Helm 4 applies server-side. The injector writes its own webhook caBundle at
runtime, so it owns that field.
Fix
helm upgrade --force-conflicts. The injector rewrites the caBundle right
away.
Lessons
- Decide the unseal trade-off on purpose. Static seal buys unattended restarts by putting the key next to the data.
- stdin is the secret channel. Arguments end up in
ps, history and logs. - Forks keep their parents’ names in odd places. OpenBao’s injector still
answers to
vault.hashicorp.com/.
Series
This post is part of Homelab Rebuild:
- Part 1: Upgrading Proxmox VE 8 to 9
- Part 2: Starting Over When the IaC Is Broken
- Part 3: When Tailscale Breaks Your Cilium Gateway
- Part 4: Living on the Tailnet
- Part 5: Secrets Without Secrets in Git (this post)
- Part 6: Certificates, DNS and a Private CA
- Part 7: Self-Hosted CI and Getting Back to IaC
- Part 8: Stateful Workloads on Unequal Hardware
- Part 9: Going Public Safely with Cloudflare Tunnel
- Part 10: When the Hypervisor Went Quiet
- Part 11: Hardening the Cluster in Five Layers
- Part 12: Observability with Coroot, and Losing pve-01
- Part 13: The Host That Kept Losing Power
- Part 14: Observability, Take Two with OpenTelemetry
- Part 15: Requests and Limits for Everything
- Part 16: Handing the Cluster to FluxCD
- Part 17: Making the Guardrails Earn Their Keep
- Part 18: When the API Server Outgrew Its VM
- Part 19: Logging In to OpenBao with Cloudflare Access and Google
- Part 20: Headlamp SSO, or Teaching kube-apiserver to Trust Cloudflare Access
- Part 21: pve-02 Loses Power, and Pi-hole Has Nowhere to Go
- Part 22: Turning Four Days of Fixes into AI Runbooks
- Part 23: microVM Sandboxes on Proxmox
- Part 24: Pi-hole Without a Home Host
- Part 25: It Was the Router
- Part 26: TLS on the Front, Plaintext Behind It
- Part 27: One Bad Power Adapter, Two Different Deaths
- Part 28: Observability, Take Three: Making It Worth Looking At
- Part 29: One Meter for Every Model, LiteLLM on Workers AI
- Part 30: Proxmox Joins the SSO, and a Plan Token Loses Admin
- Part 31: First Backups, Postgres, OpenBao and etcd to R2
- Part 32: Alerts That Reach Me, Even When the Lab Is Dead
- Part 33: The Firewall Held, the Tailnet Did Not
- Part 34: From Watching to Blocking, and a Record of Who Did What
- Part 35: Wazuh Without the 8 GB, Endpoint Security for One Mac and Three Hosts
- Part 36: The Fans Were Loud, the CPUs Were Idle
- Part 37: A Power Trip, a Search Domain and a Wildcard
- Part 38: An Autonomous AI Pentester in a Sealed Box
- Part 39: Wasm Pods on k0s, and the Label That Kept Them Packed
