OpenBao¶
OpenBao is the cluster's secret store — an open-source, Linux Foundation fork of HashiCorp Vault. It holds every secret consumed by workloads on the cluster (cloud credentials, API tokens, registry pulls, database passwords, …). Secrets are surfaced to Kubernetes as native Secret objects via the External Secrets Operator.
It is also the single most consequential component on this cluster. When OpenBao is unhappy, nothing that needs a credential works, and the failure presents as six unrelated things breaking at once. Learn its two states — sealed and unsealed — before you need to.
flowchart LR
Operator([Operator]) -->|bao CLI| Bao[(OpenBao<br/>KV v2)]
ESO[External Secrets<br/>Operator] -->|read| Bao
ESO -->|create/update| KSecret[K8s Secret]
App[App Pod] -->|env / volume| KSecret
style Bao fill:#e1f5ff,stroke:#0288d1
At a glance¶
| Namespace | openbao |
| Sync wave | 0, after networking, storage and certificates exist |
| Depends on | Rook-Ceph for its Raft volumes |
| If it is down — or merely sealed | No ExternalSecret resolves, so cert-manager cannot renew and pods that mount a materialised Secret will not start. It looks entirely healthy from the outside |
| Health check | kubectl -n openbao exec openbao-0 -- bao status → Sealed: false on all three |
| UI | vault.infra.k8s.wlkr.ch — the one platform UI not behind Authentik, deliberately |
Architecture¶
| Property | Value |
|---|---|
| Mode | HA, 3 replicas |
| Storage backend | Integrated Raft (/openbao/data, Ceph PVC per replica) |
| Audit storage | Enabled, separate PVC on rook-ceph-block |
| TLS | Disabled inside the cluster — TLS terminates at the Gateway |
| In-cluster service | http://openbao.openbao.svc.cluster.local:8200 |
| Seal | Shamir — 5 key shares, threshold 3, unsealed by hand |
The chart is the official upstream openbao/openbao-helm, pinned in application.yaml.
Chart values that are not what they look like¶
- Pod security context. OpenBao keeps the root key out of swap with
mlock, which needsIPC_LOCK— the reason the namespace enforcesprivileged. The container still dropsALLand adds back onlyIPC_LOCK. The chart'ssecurityContext.podis an if/else, not a merge: setting it replaces the default block, sorunAsUser: 100,runAsGroup: 1000andfsGroup: 1000(the chart's own defaults) are restated. Drop them and keep onlyrunAsNonRoot, and the kubelet refuses to start the container, because the image'sUSERis the nameopenbaorather than a number and cannot be proven non-root. It only shows on pod recreation, typically after a node reboot. - Image repository. The chart prepends
server.image.registry, which defaults toquay.io; the repository is thereforeopenbao/openbao, notquay.io/openbao/openbao. - Unauthenticated metrics.
/v1/sys/metricsotherwise wants a token, and the ServiceMonitor has none, so the target would sit at 403. The metrics carry counts and timings, not paths or secrets, and are readable only by what can reach port 8200 — the namespace policy's callers plus the Gateway. - Dashboard.
serverTelemetry.grafanaDashboardrenders OpenBao's upstream dashboard (grafana.com 23725) into the namespace.
Bootstrap¶
ArgoCD provisions the StatefulSet, PVCs, Services, and the vault.infra.k8s.wlkr.ch HTTPRoute. The pods go Ready within seconds, before the cluster is initialised or unsealed — see Ready does not mean unsealed. Neither happens on its own: initialisation is a one-time step, and unsealing is a step you will repeat after every restart.
Two commands do all of it:
make bao-init # initialise, unseal, configure the engine and ESO's auth
make bao-secrets # populate the five paths the cluster reads
The rest of this section is what those do, in the order they do it — worth reading once, because the failure modes are much easier to recognise if you know what was supposed to happen.
make bao-init is safe to re-run
Each configuration step is skipped if it is already in place, so a half-finished bootstrap can be resumed. Initialisation itself is not re-runnable by design: an already-initialised cluster is left alone and the command exits.
1. Initialise the cluster (one-time)¶
The command prints 5 unseal keys and an initial root token. Store them in a password manager, right now, before you run another command. Not in the terminal scrollback. Not in a note you will "tidy up later". Losing all 5 keys means the data is unrecoverable, and OpenBao is not being dramatic about that — there is no support line, no recovery flow, and no clever trick. There is just the ciphertext and no way in.
Danger
These keys protect every other secret on the cluster. There is no backup, no second chance, and no amount of Ceph replication that helps. Treat them like the root credentials they are, and keep them somewhere that does not require this cluster to be running in order to read.
Where make bao-init puts them
Rather than to the terminal, make bao-init writes the whole -format=json output to output/credentials/openbao-init.json, mode 0600, in a 0700 directory that is gitignored. That is what lets make bao-unseal work without prompting fifteen times, and it is also a plaintext copy of the keys to every secret the cluster holds, sitting on the deployment host next to the etcd encryption key. Copy them into a password manager and delete the file; unsealing then goes back to being manual, which is the same trade the rest of this repository already makes — see the limitation.
2. Unseal each replica¶
The seal is Shamir, so nothing unseals these pods but you. Repeat for
openbao-0, openbao-1, openbao-2, providing 3 of the 5 keys each time:
for pod in openbao-0 openbao-1 openbao-2; do
for i in 1 2 3; do
kubectl -n openbao exec -it "$pod" -- bao operator unseal
done
done
Three of five, three times, once per pod. Yes, it is tedious — that tedium is the entire security model, and it is the price of keeping the key material off every machine but yours.
If a replica says Vault is not initialized
bao operator init initialises one raft cluster, on the pod you ran it against — not the other two. A follower that has not joined that cluster reports Initialized: false and turns unseal keys away, which is the error the loop above produces if the replicas are not members yet. The retry_join stanzas in application.yaml are what make them join on their own as they start. service_registration "kubernetes" does not join anything; it only labels pods active and standby.
A pod that predates those stanzas, or that started before openbao-0 was
initialised, needs pointing at the leader once. It takes unseal keys
afterwards:
kubectl -n openbao exec openbao-1 -- \
bao operator raft join http://openbao-0.openbao-internal:8200
Confirm the result with:
You should see Initialized: true, Sealed: false, HA Mode: active on one pod and standby on the others.
3. Authenticate locally¶
For convenience, port-forward and point the CLI at the local instance:
kubectl -n openbao port-forward svc/openbao 8200:8200 &
export BAO_ADDR=http://127.0.0.1:8200
bao login # paste the root token
The remaining steps assume bao is configured this way.
Secret engine¶
A single KV v2 engine is mounted at the path kv/. All cluster secrets live under it. One engine, one convention, no debates six months from now about whether it was kv/ or secret/.
Layout convention¶
kv/
├── authentik/
│ └── config # secret-key, postgres-password, bootstrap-password,
│ # bootstrap-token, and the client id/secret pairs
│ # ArgoCD and Grafana read back from here
├── cert-manager/
│ └── route53 # access-key-id, secret-access-key
├── external-dns/
│ └── route53 # access-key-id, secret-access-key
├── monitoring/
│ ├── grafana-admin # password
│ └── smtp # username, password, to
└── <workload>/<purpose> # one leaf per secret
Five paths, seven ExternalSecrets: authentik/config is
read by three of them, because generating the OIDC client credentials up front
is what keeps both sides of each integration declarative. The two route53
leaves are deliberately separate and meant to be separate IAM users --
cert-manager only writes _acme-challenge TXT records, while external-dns can
repoint hostnames. Anything added later follows the same <workload>/<purpose>
shape.
The bao kv put for each path lives in a comment at the top of the
ExternalSecret that consumes it, which is the list to trust; they are
collected in Quickstart step 11.
Each leaf is a single secret with one or more keys. ExternalSecret resources reference paths as cert-manager/route53 (the KV v2 data/ prefix is added by ESO automatically).
Storing a secret¶
# and ! on a command line
Double quotes are not enough. A value containing # is fine inside them, but one containing ! is expanded by an interactive bash's history before the quotes are considered, and an unquoted # truncates the rest of the line. Either is silent, and stores a credential that looks plausible and does not work. Use single quotes — or avoid the command line entirely.
Avoiding it means writing the secret as JSON and handing bao the file, which
is what make bao-secrets does. @ is parsed before anything is sent, so a
missing file fails loudly rather than storing half a secret:
Reading a secret¶
Kubernetes auth method¶
External Secrets Operator authenticates to OpenBao using ServiceAccount JWTs. Set this up once after init:
# Enable the auth method
bao auth enable kubernetes
# Tell OpenBao how to reach the cluster's TokenReview API. The CA cert
# and host are read from the in-cluster ServiceAccount projection.
bao write auth/kubernetes/config \
kubernetes_host="https://kubernetes.default.svc"
The OpenBao ServiceAccount (openbao in namespace openbao) already has the system:auth-delegator ClusterRole bound to it via rbac.yaml in this directory, so the TokenReview calls succeed without additional setup.
Policy for ESO¶
bao policy write external-secrets - <<'EOF'
path "kv/data/*" {
capabilities = ["read"]
}
path "kv/metadata/*" {
capabilities = ["read", "list"]
}
EOF
Role binding ESO's ServiceAccount¶
bao write auth/kubernetes/role/external-secrets \
bound_service_account_names=external-secrets-vault \
bound_service_account_namespaces=external-secrets \
policies=external-secrets \
ttl=1h
The external-secrets-vault ServiceAccount is created by payload/platform/external-secrets/cluster-secret-store.yaml — see External Secrets.
Once this is done, ExternalSecret resources cluster-wide will resolve. Verify with:
The Status.Conditions of the ClusterSecretStore should report Ready=True.
Unsealing after a restart¶
OpenBao seals itself on every pod restart — every node reboot, every ArgoCD upgrade, every chart bump, every time a kubelet has a bad day. This is by design and it is not going to stop:
That checks each replica and feeds three shares to whichever are sealed, reading
them from output/credentials/openbao-init.json. It waits for a replica that has
not joined the raft cluster yet rather than failing on it, and it is idempotent —
on an unsealed cluster it says so and stops.
With the key file deleted, which is the correct end state, it is the loop below instead, three shares per pod:
for pod in openbao-0 openbao-1 openbao-2; do
kubectl -n openbao exec "$pod" -- bao status >/dev/null 2>&1
case $? in
0) echo "$pod: already unsealed" ;;
2) for i in 1 2 3; do
kubectl -n openbao exec -it "$pod" -- bao operator unseal
done ;;
*) echo "$pod: OpenBao did not answer, check the pod" ;;
esac
done
bao status exits 0 when unsealed, 2 when sealed and 1 when it cannot
reach the server, and kubectl exec passes that exit code through.
Ready does not mean unsealed¶
The readiness probe calls /v1/sys/health with sealedcode=204 and
uninitcode=204, so a sealed or uninitialised replica answers healthy and the
pod goes Ready seconds after it starts. That is deliberate: if readiness
waited for unsealing, a StatefulSet rolling update would stop after the first
pod until someone typed in the keys, and every chart bump and Kured reboot
would hang half-applied.
The cost is that kubectl get pods cannot tell you whether OpenBao is usable.
Only bao status can, and it is what make bao-unseal and the loop above
check.
Nothing does this for you. There is no auto-unseal seal configured, so a
reboot at 03:00 leaves the cluster running and its secret store shut until
someone with the key shares logs in. Plan for that rather than being surprised
by it: while OpenBao is sealed no ExternalSecret resolves, so cert-manager
loses the Route53 credentials it needs to renew certificates.
The failure is slow, which is what makes it dangerous. Nothing breaks the day OpenBao seals; things break sixty days later when a certificate expires and nobody connects the two events. See OpenBao needs an operator to unseal it.
Backups¶
The Raft storage backend supports snapshotting:
Snapshots include all KV data and OpenBao's own config (policies, roles, mounts). Store them off-cluster — a snapshot on a PVC inside the cluster it is meant to rebuild is decoration. Restore with bao operator raft snapshot restore. And note the obvious: the snapshot is encrypted with a key that exists only in those five shares, so it is exactly as recoverable as your key custody is.