OpenBao is the store of record, durable as a snapshot lineage, active on the primary cloud with restore-based fallback
Status: Accepted
Date: 2026-09-02
Deciders: Smana (Platform Owner)
Related Design: docs/superpowers/specs/2026-09-02-openbao-store-of-record-design.md (repository path; designs are not published)
Related: ADR-0025 — the cost record this
changes the mechanism of; ADR-0027 — the class
OpenBao moves into; ADR-0011 — the engine
Context
ADR-0025 made the cloud’s managed secret store the store of record because it is always-on and outlives the platform, and said OpenBao “remains the target” for a deployment that runs continuously. Two facts, both verified on 2026-09-02, meant that target could never be reached by leaving the process on:
- The seal key was destroyed with the platform. It lived in the ephemeral
cluster stack with a 10-day deletion window, so every rebuild minted a new
key and every earlier snapshot became ciphertext.
devmode ran thefilebackend and never took one, which is why nothing noticed. - The restore procedure was a documented hypothesis. Nothing had ever executed it.
A store of record has to survive the platform. The insight is that the running process does not have to be what survives. Everything a rebuilt OpenBao needs to come back is small and cheap to keep: a seal key, the material that bootstraps the node, and the newest snapshot. Call that a lineage, keep it outside every ephemeral stack, and the process becomes derived state — off between runs in the reference platform, always-on in production, the same design either way.
Decision Drivers
- Cost of the idle floor. ADR-0025’s constraint is real: the reference must not pay for an always-on secrets cluster. It may pay ~$1/month for a key.
- Restorability, proven. Every deploy of the reference platform and a weekly drill must exercise the restore path.
- One root of trust, offline. The AWS root private key used to sit inside the
live PKI mount. Taking it out is a hand-performed ceremony, and it has now been
performed: the root signs per-cloud intermediates offline, only the intermediate
bundle enters a networked store, and the root certificate is committed as
.github/openbao-root-ca.pemso the drill can verify against it. Both clouds now match. The procedure is on the PKI & Secrets page. - No long-lived credential to reach OpenBao. Workloads authenticate with their cluster’s ServiceAccount tokens.
- A fallback that survives an AWS regional outage, stated with its limits.
- GCP-only deployments keep working with no AWS dependency (ADR-0024).
Considered Options
Option 1: Managed stores remain the store of record (status quo)
Pros: nothing to build. Cons: the platform documents a target it does not run; the seal-key and restore defects stay; two stores with a placement rule to learn.
Option 2: Per-cloud OpenBaos, each authoritative for its cluster
Pros: no cross-cloud dependency. Cons: a value both clouds need is seeded twice and drifts (ADR-0025’s own negative); two policy models; two roots of trust or a shared root with two issuing chains and no single audit trail.
Rejected as a reduction of that drift surface, not its removal. The chosen option’s own Negatives concede a residual hand-seeded tier of five secrets per cloud, and Stage 1 leaves each cloud with its own lineage, bucket, intermediate and JWT mount — so what Option 2 loses is the convergence, not the last duplicated value.
Option 3: One OpenBao, no fallback
Pros: simplest. Cons: a GCP-only platform cannot authenticate to anything, which is the dependency ADR-0024 removed for the identity provider.
Option 4: One active OpenBao on the primary cloud, a snapshot lineage, a standby restoring under the same seal (chosen)
Pros: one store, one policy model, one root; the floor moves by one key; restore is exercised on every deploy; fallback survives a regional outage. Cons: the standby depends on AWS KMS (mitigated by a multi-region replica); a bootstrap tier of five secrets per cloud remains in the managed stores; new cross-cloud plumbing (Tailscale egress, two federated roles).
Rejected on the seal: a Shamir-sealed standby
Survives the loss of the AWS account, but every restart becomes a human typing a key share and Raft auto-join breaks. Seal migration during failover is not an option either: it requires both seals reachable, which an outage denies.
Rejected on the PKI: AWS Private CA / GCP CAS, and cert-manager’s CA issuer
The clouds’ managed CAs cost about $400/month per CA on AWS and are not portable.
cert-manager’s built-in CA issuer costs nothing, and is rejected for what it
would remove rather than for where it stores a key: it replaces the self-hosted
PKI — mounts, roles, policies, an audited issuing path — that this repository
exists to demonstrate, and it has no answer for the non-certificate half of
OpenBao’s job. “It keeps the intermediate key in a Kubernetes Secret” is not
the honest discriminator, because the chosen option keeps that same key in a
networked secret store too (certificates/priv.aws.ogenki.io/intermediate-ca)
and imports it into the live mount. The real difference is blast radius, not
kind: a Kubernetes Secret is readable by anything with get secrets in that
namespace and by any workload that mounts it, and it is snapshot into etcd
backups; the Secrets Manager entry is reachable only by the OpenTofu principal
that runs the management stack, is IAM-auditable per read, and is never
projected into a pod. Both are networked. One has a smaller and more legible
set of readers.
Rejected earlier: SOPS
ADR-0025, option 3.
Decision Outcome
Option 4. OpenBao is the store of record. Its durable form is the lineage:
alias/openbao-seal (multi-region, replica in eu-west-1), the snapshot
bucket and its key, and five bootstrap secrets per cloud (the CA chain, server
TLS material, root token, recovery keys, the offline-signed intermediate — the
CA chain is read first, before rehydrate, on every deploy). The process is
rehydrated from the newest snapshot on every boot; a last snapshot is taken
before every destroy. One instance becomes the active one, on AWS
(ADR-0027 primary-cloud singleton — OpenBao
changes class from per-cloud, with the one property the other singletons lack:
its relocation carries state). Workloads on both clusters authenticate through
the JWT method against their cluster’s OIDC issuer. One offline root signs one
intermediate per lineage. A GCP standby restores the GCS mirror sealed by the
same AWS key through OIDC federation; the fallback survives an AWS regional
outage and the loss of AWS compute or Secrets Manager, not the loss of the
AWS account — a deliberate trade for keeping auto-unseal.
Staged, and the staging matters for what a reader should expect to find on the clusters. Stage 1 is per-cloud. It builds the lineage, rehydrate, JWT auth, the offline root on AWS, the mirror, the drill and one executed cross-cloud failover — while leaving in place:
- Two OpenBao instances, each authoritative for its own cluster.
jwt/gcp-0is created againsthttps://bao.priv.gcp.ogenki.io:8200(opentofu/gcp/gke/configure/openbao.tf, whosevaultprovider addresses the GCP node), andsecurity/gcp-0/openbao/kustomization.yamllistsopenbao-endpoint/local.gcp-0reaches its own cloud’s OpenBao in the normal posture; nothing routes it to the AWS instance. - A lineage, snapshot bucket and intermediate CA per cloud, with the hand-seeded bootstrap tier duplicated on both.
- The managed store still the store of record.
So “one instance active on AWS serving both clusters” is the decision’s end
state, not Stage 1’s. Stage 2 is what converges it: it repoints the
ClusterSecretStore and migrates about 36 secrets, and it is also where gcp-0
switches to the remote endpoint form and stops being authoritative for itself.
Until then the two clouds run the same design twice, which is why the residual
drift in the Negatives below is real rather than theoretical.
Consequences
Positive
- The idle floor moves from about $23 to about $24/month, and the platform runs
the design it documents. The added dollar is the seal key’s
eu-west-1replica, which KMS bills as a key of its own; confirmed on the live account once the lineage stack was applied. The buckets are rounding error at 74 KB per snapshot. - Restore stops being a hypothesis: every deploy performs one, and CI drills it weekly with no recovery keys in reach.
- No AppRole
SecretIDexists anywhere; the two AppRole entries per cloud are gone from the managed stores. - Tailnet clients trust one root, and no root CA private key is on a
networked system. The intermediate’s private key still is: it lives in
certificates/priv.aws.ogenki.io/intermediate-cain AWS Secrets Manager (openbao-priv-gcp-intermediate-caon GCP) and is imported into the live PKI mount byopentofu/aws/openbao/management/pki.tf. That is the tier this design moves off a networked system, and the tier it does not.
Negative
The standby is coupled to AWS KMS. Mitigation: multi-region replica; the limit is stated in the failover guide.
Five bootstrap secrets per cloud must exist on both clouds for a fallback to be bootstrappable; they are seeded by hand and can drift. Mitigation: the list is short, and the failover guide’s preconditions enumerate all five with the code that reads each one, as peacetime checks with the commands to run them.
secret-store.sh checkdoes not cover this tier, and cannot. Itscmd_checkenumerates keys by readingexternalsecrets.external-secrets.io -Afrom a live cluster and resolving onlyspec.data[].remoteRef.keyandspec.dataFrom[].extract.key. Of the five, exactly one — the CA chain — is named by any of those fields. The root token, recovery keys, intermediate CA and server certificate are read by OpenTofu providers and boot scripts, so ESO never names them at all andcheckcannot see them. It also needs a running cluster with External Secrets installed, which is exactly what a fallback bootstrap does not have.The GCP snapshot bucket becomes a mixed-seal namespace during a failover. Once a standby has run under
awskms, both the mirrored objects and its own are AWS-sealed, and agcpckmsnode cannot unwrap any of them. That much is inherent to the design and is still true.As accepted on 2026-09-02, the consequence was sharper than that: those objects were indistinguishable by name.
openbao-snapshot.shwrote every one as<timestamp>.snap— flat, identical on both clouds — so nothing in a name, and nothinglatest_snapshot()read, recorded which seal had wrapped it; the selector simply picked the newest. A latergcpckmsnode therefore selected a snapshot it could not unwrap, restored it, and stranded itself holding throwaway keys. The mitigation was procedural: the failover guide’s failback destroyed the standby withTM_OPENBAO_SKIP_SNAPSHOT=truerather than flipping its seal, and asked an operator to move the AWS-sealed objects aside — or promote the lastgcpckms-era object by name — beforegcp-0returned to GCP-only mode. This record named the code-level fix that would remove it, “a seal segment in the key, or aseal=object attributelatest_snapshot()filters on”, and deferred it: not done in Stage 1; the first thing to do if a second failover is ever expected.Amended 2026-09-02 — closed the same day, by the first of those two. Objects are now written
<UTC timestamp>-<seal>.snap, e.g.2026-09-02T041500Z-awskms.snap. The seal is read from the writing node’s own unauthenticated/v1/sys/seal-statusrather than from configuration, so it records what actually wrapped the bytes; the timestamp stays leading and fixed-width, so lexicographic order remains chronological across seals. Both selection paths — the gate inrehydrate_openbao(scripts/openbao-config.sh) and the one inrestore(container-images/openbao-snapshot/openbao-snapshot.sh) — compare the newest object’s segment against the node’s own seal and refuse beforebao operator initand beforesnapshot restore -force, naming both seals. An object carrying no seal segment is never selected, and is named in the refusal rather than skipped silently.OPENBAO_SNAPSHOT_SKIP_FOREIGN_SEAL=truerestores the newest object the node’s seal can unwrap instead — the failback case — and is deliberately not the default, because skipping a newer snapshot discards writes. Snapshot imagev0.3.1. The bucket is still a mixed-seal namespace: what is gone is its illegibility, and with it the bucket-mutation chore the failback used to prescribe — step 4 of the failover guide is now a decision about whose writes to discard, taken against a listing that answers the question.New moving parts: a systemd timer refreshing a web-identity token on the GCP node, a Storage Transfer job, an egress
ProxyGrouppath, two federated roles.A node recreated between snapshots loses writes since the last one (RPO = the snapshot cadence: daily in the reference, hourly in production).
Neutral
- ADR-0025’s cost constraint is unchanged; it now bounds whether the process is left on between runs, not where the store of record is.
- The
ClusterSecretStorerepoint (Stage 2) is the change ADR-0025 always said it would be.
Implementation Notes
Stage 1 plan: docs/superpowers/plans/2026-09-02-openbao-store-of-record-stage1.md.
Lineage stacks: opentofu/aws/openbao/lineage/, opentofu/gcp/openbao/lineage/.
Rehydrate: ./scripts/openbao-config.sh rehydrate, called by both management
stacks’ deploy scripts. Auth mounts: opentofu/{aws/eks,gcp/gke}/configure/openbao.tf.
Fallback runbook: Cross-cloud failover.
References
- ADR-0025, ADR-0027, ADR-0024, ADR-0019
- OpenBao, PKI & Secrets, What it costs
- OpenBao docs: seal migration requires both seals reachable; Raft nodes must share a seal configuration