OpenBao ops
Operational SOP for the in-cluster OpenBao deployment (k0s/platform/openbao-values.yaml). Covers the 6-playbook ops chain under ansible/playbooks/k0s/ops/ plus the ESO consumer side.
The chart deploys OpenBao in standalone + file-storage mode (server.standalone.enabled: true, storage "file", tls_disable = 1 on the internal listener — TLS is terminated at the internal Cilium Gateway, not at the pod). There is no Raft, no KMS auto-unseal, no Transit auto-unseal. Every pod restart is a manual unseal. Auto-unseal is a separate planning track (TODO; not written yet).
Background on why standalone+file rather than Raft+integrated-storage: it’s the simplest mode and our scale doesn’t justify Raft yet. Switching to Raft later is a data-migration exercise (bao operator migrate), not an in-place toggle.
Concepts: master key, shares, root token, rekey token, ESO
The procedures below touch five distinct pieces of cryptographic and authentication material. They’re constantly conflated; the SOP only makes sense once they’re separated.
Master key. A single 256-bit AES key. Encrypts every secret on the PVC; nothing else decrypts those bytes. Generated once at bao operator init, never written to disk in plaintext, never seen by humans. Lives only in RAM, only while the vault is unsealed. The whole seal/unseal design exists to preserve “master key on disk = never” — if the PVC is stolen, the data is unreadable without the key, which is somewhere else entirely.
Unseal shares. 5 base64 strings (~88 chars each), each a polynomial point in Shamir’s Secret Sharing scheme over the master key. Any 3 of the 5 can mathematically reconstruct the master key; 2 or fewer reveal nothing usable. Produced at init time, printed once, never regenerated by OpenBao on its own. Humans paste them into bao operator unseal at every pod start; after 3 have been received OpenBao reconstructs the master key into RAM and the vault transitions to unsealed. The shares themselves are discarded from RAM after reconstruction.
In our setup all 5 shares live together in k0s/platform/secrets/openbao-keys.sops.yaml, SOPS-encrypted (whole-file) to both age recipients. That’s a deliberate trade — the math defends against partial-leak (“attacker gets 2 of 5 shares”) but in practice all 5 live in one file, so the threat model collapses to “attacker has the age key, or doesn’t.” The seal still protects against “attacker has the PVC but not the age key” — the realistic theft scenario for an on-prem cluster.
Root token. Issued once at init, printed once, revoked by configure-openbao.yaml after it mints the rekey_token. We deliberately don’t keep the root token around: OpenBao 2.5.4 disables sys/generate-root/attempt, so the post-revoke recovery path for a lost rekey token is full re-init (delete Helm release + PVC + re-run init/unseal/configure). That cost is acceptable in exchange for not having a god-mode credential sitting in the SOPS file long-term.
Rekey token. A scoped, periodic (720h), orphan token whose policy includes only path "sys/rotate/root/*" { capabilities = [..., "sudo"] }. Minted by configure-openbao.yaml immediately before revoking the root token. Replaces root_token in openbao-keys.sops.yaml. Sufficient for Shamir rotation (rekey-openbao.yaml), NOT for any other admin path. The reason this is an orphan token: parent revocation cascades, and we want the rekey token’s lifetime decoupled from any auth-method tree.
Kubernetes auth + KV v2 + ESO. configure-openbao.yaml also enables the kubernetes/ auth method (validates SA JWTs against the cluster’s TokenReview API), mounts a kv/ v2 secrets engine, writes four policies (admin, rekey, external-secrets-operator-eso, kv-writer), and creates two roles bound to specific ServiceAccounts:
external-secrets-operator— SAexternal-secrets.external-secrets, ttl 24h, policyexternal-secrets-operator-eso(read-only onkv/data/eso/*). Used by ESO to materialize Kubernetes Secrets from KV.kv-writer— SAopenbao-operator.platform, ttl 1h, policykv-writer(write anywhere underkv/). Used bykv-patch.yamlto bump a single field without touching siblings, andkv-put.yamlfor create-only seeding.
Lifecycle:
make k0s-openbao-init (one time per PVC, ever) ├─▶ generates master key (RAM only during init) ├─▶ splits into 5 shares ─▶ openbao-keys.sops.yaml └─▶ generates root token ─▶ openbao-keys.sops.yaml
make k0s-openbao-unseal (every pod start) └─▶ reads 3 shares from SOPS, posts to /sys/unseal until unsealed
make k0s-openbao-configure (one time, immediately after first init+unseal) ├─▶ enables kubernetes/ auth + kv/ v2 ├─▶ writes 4 policies + 2 k8s auth roles + initial KV seeds ├─▶ mints rekey_token (orphan, periodic 720h, policy=rekey) ├─▶ atomically swaps root_token → rekey_token in openbao-keys.sops.yaml └─▶ revokes root_token
make k0s-eso (after configure) └─▶ deploys ESO + ClusterSecretStore + 3 ExternalSecrets
make k0s-openbao-rekey (periodic; rotates Shamir shares) ├─▶ phase 1: /sys/rotate/root/init (new shares; old still valid) ├─▶ phase 2: atomic SOPS rewrite (tempfile + verify + mv -f) ├─▶ phase 3: /sys/rotate/root/verify (commits new; invalidates old) └─▶ token field unchanged (rekey_token preserved)
make k0s-openbao-kv-patch SVC=… FIELD=… (any time, single field) └─▶ SA JWT (1h) → auth/kubernetes/login → bao kv patchmake k0s-openbao-kv-put (create-only seeding, idempotent)Status
Reference procedure — in production use since 2026-05. All 5 playbooks under ansible/playbooks/k0s/ops/ are operational. The make k0s chain calls k0s-openbao-bootstrap (a non-interactive wrapper) between k0s-platform and k0s-eso so first-time bring-up + per-restart unseal happen as part of the normal pipeline. Operator-driven re-runs use the interactive Make targets below.
What gets touched
k0s/platform/secrets/openbao-keys.sops.yaml— in-repo, SOPS-encrypted whole-file (k0s/.sops.yamlrule 2). Both age recipients (init+operator).- Pre-configure fields:
unseal_keys_b64: [5],unseal_keys_hex: [5],unseal_shares: 5,unseal_threshold: 3,recovery_*: null,root_token: s.xxx. - Post-configure fields: same as above except
root_tokenis replaced byrekey_token: s.xxx. - Created by
init-openbao.yaml; mutated byconfigure-openbao.yamlandrekey-openbao.yaml(atomic temp-write + sops-encrypt + round-trip verify +mv -f).
- Pre-configure fields:
k0s/platform/secrets/initial-kv-seeds.sops.yaml— in-repo, SOPS-encrypted whole-file. Top-level keys = service names (forgejo,grafana); inner dicts ={ username, password }etc. Read atconfigure-openbao.yamltime to populatekv/eso/<svc>for any missing path (existing paths are not modified — that’skv-patch.yaml’s job).openbaoPVC (openbao-data-openbao-0in theplatformnamespace, 10 Gi onlocal-path) —bao operator initwrites the encrypted master key + initial keyring to/openbao/datainside the pod. After this, the PVC is the source of truth for what the shares can decrypt; if the PVC is destroyed, the shares are useless.openbao-0pod’s in-memory seal state — toggled bybao operator unseal. Resets to sealed on every pod restart. Not persisted.- k8s API objects created by
configure-openbao.yaml:Auth method: kubernetes/Secrets engine: kv/(KV v2)- 4 policies:
admin/rekey/external-secrets-operator-eso/kv-writer - 2 k8s auth roles:
external-secrets-operator/kv-writer
- ESO-materialized Secrets (created by
deploy-eso.yaml, ongoing refresh interval 1m) — originally three (forgejo-admin,eso-test,grafana-admin); the roster has since grown to ~20. Enumerate live:kubectl get externalsecret -A.
Pre-flight checklist (interactive ops)
-
KUBECONFIG=/configs/k0s/configset in the workspace;kubectl get nodesreturns Ready. (SOPS_AGE_KEY_FILE=/configs/sops/age/keys.txtis preset in the workspace Dockerfile.) - Helm release installed:
helm -n platform list | grep openbaoshowsdeployed. - For init: pod sealed + uninitialized —
kubectl -n platform exec openbao-0 -- bao statusreturns exit code 2 and showsInitialized false, Sealed true. - For configure / rekey: working tree clean for
k0s/platform/secrets/openbao-keys.sops.yaml(git statusreports no uncommitted changes). Both playbooks abort otherwise to prevent overwriting in-progress edits. The bootstrap wrapper passes-e skip_git_check=truefor the configure step only (CI/non-interactive use). - DNS resolves:
dig +short bao.rrchnm.internalreturns theinternalGateway IP (peransible/vars/k0s-gateways.yaml). Not strictly required for the CLI-via-exec path, but confirms ingress is intact for UI use.
Procedures
All Make targets below run from the workspace container.
Bootstrap chain (non-interactive)
make k0s-openbao-bootstrapThe operative path during make k0s. Idempotent end-to-end:
- Waits for
openbao-0to be Running. - Runs
init-openbao.yamlwithconfirm=initonly ifbao statusreportsinitialized=false. - Always runs
unseal-openbao.yaml(skips already-unsealed pods). - Runs
configure-openbao.yamlwithconfirm=configure -e skip_git_check=trueonly if the SOPS file’s token field isroot_token(i.e., configure hasn’t run yet). After successful configure the file hasrekey_tokenand re-runs short-circuit.
Leaves the SOPS file dirty for the operator to commit manually (intentional — the operator should review the diff before pushing).
Initial init (one-shot)
make k0s-openbao-initEquivalent to ansible-playbook playbooks/k0s/ops/init-openbao.yaml -e confirm=init. Prompts for literal init confirmation.
What happens:
- Pre-flight: asserts
openbao-0isRunning; assertsbao statusreturnsinitialized=false; assertsopenbao-keys.sops.yamldoes not exist. bao operator init -key-shares=5 -key-threshold=3 -format=jsonagainstopenbao-0.- Parses JSON output → renders YAML →
sops --encrypt --in-placewithchdir: k0s/so SOPS walks up tok0s/.sops.yamland resolves rule 2.
Never re-run against an existing PVC — the playbook’s pre-flight prevents this, but if you bypass the gate manually you’ll get Vault is already initialized from bao and a dangling file write.
Initial unseal (recurring)
make k0s-openbao-unsealEquivalent to ansible-playbook playbooks/k0s/ops/unseal-openbao.yaml. No confirmation prompt — fully idempotent and safe to re-run at any time.
What happens:
- Reads unseal shares from
openbao-keys.sops.yamlviacommunity.sops.sopslookup. - Discovers every openbao server pod (
label=app.kubernetes.io/name=openbao,component=server). - For each pod (sorted), checks
bao status; ifsealed=true, postsunseal_threshold(=3) shares to/sys/unsealviakubectl exec.
Run this after any pod restart — make k0s reapply, node drain/uncordon, OOMKill, manual kubectl delete pod openbao-0, etc. The bootstrap wrapper invokes it on every make k0s run.
Configure (one-shot, immediately after first init+unseal)
make k0s-openbao-configureEquivalent to ansible-playbook playbooks/k0s/ops/configure-openbao.yaml -e confirm=configure. Prompts for literal configure confirmation; aborts if openbao-keys.sops.yaml is dirty in git (unless -e skip_git_check=true).
What happens (in order):
- Authenticates as the current
root_token. - Enables
kubernetes/auth method +kv/v2 secrets engine. - Writes 4 policies (
admin,rekey,external-secrets-operator-eso,kv-writer). - Creates 2 k8s auth roles (
external-secrets-operator,kv-writer) bound to specific SAs. - Seeds
kv/eso/eso-testwith{foo, timestamp}for ESO smoke-testing. - Loops
initial-kv-seeds.sops.yaml: for each service whosekv/eso/<svc>does NOT exist, writes the seed values. Existing paths are not modified (this is by design — once a service is up, usekv-patch.yamlto change a field). - Mints
rekey_token:bao token create -policy=rekey -orphan -period=720h -no-default-policy. - Atomic SOPS file mutation: write
openbao-keys.new.sops.yaml(token field renamedroot_token → rekey_token) →sops --encrypt --in-place(chdirk0s/) → SOPS round-trip verify →mv -fover the original. bao token revoke -selfusing the capturedroot_token.
Side effect: the root token is now invalid. Re-running this playbook fails (no valid root token to authenticate with). For policy/auth changes after configure has run, the path is full re-init (delete Helm release + PVC + re-run init/unseal/configure). This is why operator changes to policy/role definitions should be made in the playbook BEFORE first configure on a given PVC.
Rekey (periodic; rotates Shamir shares)
make k0s-openbao-rekeyEquivalent to ansible-playbook playbooks/k0s/ops/rekey-openbao.yaml -e confirm=rekey. Prompts for literal rekey confirmation; aborts on uncommitted openbao-keys.sops.yaml (no skip-git-check escape hatch — rekey must be a deliberate operator action).
What happens (3-phase via OpenBao 2.5.0+ sys/rotate/root/{init,update,verify} API with require_verification=true):
- Pre-tasks: cancels any pending rotation (
bao delete sys/rotate/root/init). Authenticates as the current token from the SOPS file. - Phase 1 (init): posts
{secret_shares: 5, secret_threshold: 3, require_verification: true}to/sys/rotate/root/init. OpenBao returns anonce+ new shares; old shares remain valid for unseal at this point. - Phase 2 (atomic persist): writes
openbao-keys.new.sops.yamlwith new shares + same token →sops --encrypt --in-place→ SOPS round-trip verify (decrypt the new file and re-parse) →mv -fover original. Failure beforemv -fleaves the original untouched. - Phase 3 (verify): posts new shares to
/sys/rotate/root/verify. OpenBao commits the new shares and invalidates the old. Past this point, only the new shares can unseal the pod. - Confirms
bao statusstill showssealed=false.
The token field (rekey_token or root_token) is preserved. Rekey rotates only the Shamir shares — it doesn’t touch the master key, the root/rekey token, or anything in kv/.
Default cadence: ~annually, or after any operator with share-knowledge leaves. The 3-phase verification mode means a network blip mid-rekey doesn’t strand the cluster (old shares stay valid until phase 3 commits).
Rotate or seed a single KV value
make k0s-openbao-kv-patch SVC=<service> FIELD=<field> # change ONE field on an existing pathmake k0s-openbao-kv-put # create-only seeding from initial-kv-seeds (idempotent)make k0s-openbao-kv-reseed # re-assert seedsmake k0s-openbao-kv-shell # interactive kv-writer shellkv-patch (playbook ops/kv-patch.yaml) prompts for the new value
interactively — no value= Make var by design, keeping the secret out of
make argv and shell history. What happens:
- Mints a 1h SA token for
openbao-operator.platformviakubectl create token. bao login -method=kubernetes role=kv-writer jwt=<the SA token>— gets akv-writer-policy token.bao kv list kv/eso/to discover existing services (printed for operator reference).- Prompts for
value(single field; one value at a time). bao kv patch kv/eso/<svc> <field>=<value>— patches the single field, preserves siblings.- Reads back
bao kv metadata get kv/eso/<svc>to confirmcurrent_versionbumped by 1.
kv-put (playbook ops/kv-put.yaml) is the create-only sibling: it writes any
path in initial-kv-seeds.sops.yaml that does not yet exist, and never touches
existing paths. The playbook headers are authoritative for flags.
ESO refresh. Services consuming their ExternalSecret via env-var injection
get the materialized Secret within the refresh interval, but the pod needs
kubectl rollout restart to pick up the new env var.
Token scope: the kv-writer policy can write under kv/ but cannot read
sys/*, other auth methods, etc.
Recovery scenarios
Lost openbao-keys.sops.yaml, vault is initialized
Catastrophic — the master key can’t be reconstructed without 3 of the 5 shares. Options:
- Decrypt from a recent borg snapshot of the operator-side
/configsmount (if SOPS recipients are in the same age key as the snapshot). If recoverable,git checkoutor copy the file back, thenmake k0s-openbao-unsealshould work. - If the file is genuinely gone: tear down the PVC and re-init. Run
helm -n platform uninstall openbao,kubectl delete pvc -n platform openbao-data-openbao-0, then re-runmake k0s-platform→make k0s-openbao-bootstrap. You lose every secret inkv/— they have to be re-seeded from operator memory / external sources. ESO materializations re-sync on next refresh onceconfigure-openbao.yamlre-seeds viainitial-kv-seeds.sops.yaml.
openbao-keys.sops.yaml exists but a share is corrupted
Less catastrophic — you have 5 shares and only need 3 for unseal. If one is malformed (truncated, base64-invalid), edit the file via sops openbao-keys.sops.yaml and verify the others by manually attempting bao operator unseal <share> with shares 2/3/4 (or whatever subset avoids the suspect one). If you can unseal with a 3-share subset, the spare shares can stay broken until next rekey, which regenerates all 5.
If 3 or more shares are corrupted, treat as “lost SOPS file” above.
Lost rekey_token, configure has already run
Per the configure rationale: full re-init. OpenBao 2.5.4 disables sys/generate-root/attempt, so there’s no way to mint a new root token from the shares. Tear down + recreate the PVC as above.
This is a deliberate cost — we prefer the brutality of “tear down + reseed” over a permanent god-mode credential. Rotate keep-around tokens by re-running configure-openbao.yaml… except we can’t, because configure assumes the root token. So in practice the rekey-token’s lifespan is “until the PVC dies” and that’s fine.
Verification commands
# Status overallkubectl -n platform exec openbao-0 -- bao status# Expect: Initialized true, Sealed false, Version 2.5.x, HA Enabled false
# Authenticate with the rekey_token (post-configure)sops -d k0s/platform/secrets/openbao-keys.sops.yaml | yq -r .rekey_token | \ xargs -I% kubectl -n platform exec openbao-0 -- env BAO_TOKEN=% bao token lookup -self# Expect: policies=[rekey], orphan=true, period=720h
# List KV paths (using the kv-writer SA token)TOKEN=$(kubectl -n platform create token openbao-operator --duration=1h)kubectl -n platform exec openbao-0 -- env BAO_TOKEN= bao login -method=kubernetes role=kv-writer jwt=$TOKEN# Expect a token with policies=[kv-writer]; then:kubectl -n platform exec openbao-0 -- bao kv list kv/eso/# Expect: eso-test, forgejo, grafana (at minimum)
# ESO materializationkubectl -n platform get externalsecret# Expect: forgejo-admin, eso-test — both SecretSynced=Truekubectl -n observability get externalsecret# Expect: grafana-admin SecretSynced=True
# Forgejo admin secret content (proves the kv → ESO path end-to-end)kubectl -n platform get secret forgejo-admin -o jsonpath='{.data.username}' | base64 -d# Expect the value in initial-kv-seeds.sops.yaml's forgejo.username fieldRollback
There is no “undo” for these operations. Specific paths:
init-openbao.yamlfailed mid-write: if the SOPS file was created with partial content, delete it manually + tear down the PVC + re-init. Don’t try to repair a partial file.configure-openbao.yamlfailed after step 7 (rekey_token minted) but before step 9 (root token revoke): both tokens are valid; the SOPS file may or may not have been mutated. Inspect the file; if it hasrekey_token, manually revokeroot_tokenviakubectl exec openbao-0 -- bao token revoke <root>. If it hasroot_token, the rekey token is orphaned and harmless (it’ll expire in 720h).rekey-openbao.yamlfailed between phase 2 (file mutated) and phase 3 (verify): the SOPS file now has new shares, but the vault still uses the old shares for unseal until phase 3 commits. Re-runrekey-openbao.yaml— its pre-tasks cancel pending rotation; the next run starts a fresh 3-phase cycle. If shares are completely confused, restore the SOPS file fromgit(it was clean before the run thanks to the dirty-tree check) and re-run.
See also
- Bootstrap chain:
Makefilek0s-openbao-bootstraptarget. - All 6 playbooks:
ansible/playbooks/k0s/ops/{init,configure,unseal,rekey}-openbao.yaml+kv-patch.yaml+kv-put.yaml. - ESO consumer side:
ansible/playbooks/k0s/deploy-eso.yaml+k0s/external-secrets/{cluster-secret-store,eso-test-externalsecret,forgejo-admin-externalsecret,grafana-admin-externalsecret}.yaml. - SOPS file:
k0s/platform/secrets/openbao-keys.sops.yaml; rule ink0s/.sops.yaml(rule 2 — whole-file, both age recipients). - Initial KV seeds:
k0s/platform/secrets/initial-kv-seeds.sops.yaml. - ESO config vars:
ansible/vars/eso-config.yaml. - Concepts background (master-key / shares / token separation): the “Concepts” section above.
- SOPS-key rotation procedure:
sops-key-rotation. - Identity / OIDC plan:
kanidm— OpenBao’s role in the broader IAM picture.