Follows the copyparty/ergo updater idiom -- check/update/run/install/uninstall, a conf file the environment overrides, the version pinned into .env so the running release is explicit, DRY_RUN -- but inverts its central assumption. copyparty and ergo come back by themselves after a recreate. OpenBao comes back SEALED: with the default Shamir seal a restart needs three unseal keys typed in by a human. A scheduled `latest` update would therefore take the vault offline at 03:00 and leave it there. So the scheduled path defaults to UPDATE_POLICY= notify and never changes the running version; `install` schedules a daily CHECK and says so. UPDATE_POLICY=auto opts in, and is STILL refused unless an uncommented `seal` stanza is present in config.hcl -- only auto-unseal makes an unattended update defensible. `update` preflights before touching anything, because every one of these fails worse halfway through than up front: - the container must be running; - the vault must be UNSEALED, since a sealed vault cannot produce a snapshot and there would be no rollback plan; - BAO_TOKEN must be present, because the snapshot is token-gated on sys/storage/raft/snapshot; - the target must not cross into 2.7.x while a built-in seal "pkcs11" stanza is active. That stanza is REMOVED in 2.7.0, not deprecated, so the vault would start with no way to unseal at all. The snapshot is the rollback plan, not a formality: OpenBao's upgrade guide states that reverting the image alone does not roll back the data store. It is streamed out with `exec -T ... cat` rather than `compose cp`, which emits a TAR wrapper that will not restore; written 0600 to /var/backups/openbao; and checked for being a non-empty valid gzip archive, with a failure treated as fatal. SKIP_SNAPSHOT=1 exists and warns exactly what it costs. A failed pull or start rolls the OPENBAO_TAG pin back and restarts the previous version. BAO_TOKEN is deliberately never written to the conf file: a long-lived root token sitting next to the vault it opens defeats the vault. Verified: the 2.7-with-active-pkcs11 refusal and its three negative cases (2.6.2 active, 3.x active, 2.7 commented); auto-unseal detection distinguishing a commented stanza from a live one; set_env/env_get; the release-tag parse with and without a leading v; and all four `run` policy branches, including that auto refuses without auto-unseal and that an unknown policy dies. Not verified without a live host: the snapshot, pull and recreate themselves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
openbao
Hardened OpenBao — the tape-encryption key store for Kanrisha (the LTO tape-archive system; separate repo, separate host). Deliberately separate from the tape host: a compromise of the tape node must not reach the vault, and OpenBao's TLS/unseal lifecycle is cleaner on its own box.
Unlike the other stacks here there is no Caddy / Let's Encrypt — a secrets store terminates TLS itself and is reached over the LAN, not the public internet.
- Native TLS on the listener — self-signed by default, or a CA-signed cert from your Smallstep CA over ACME.
- Integrated raft storage — snapshot-based DR.
- Swap disabled — key material never hits disk. (Not mlock: OpenBao removed
mlock support, so
disable_mlockmust not appear in config.hcl at all.) - Manual unseal by default, or PKCS#11 HSM auto-unseal.
⚠️ This vault is the sole recovery path for encrypted tapes. Losing the OpenBao data and the unseal keys/root token loses every encrypted tape. Take raft snapshots and store the unseal material out of band (below).
Required .env values
| Variable | Notes |
|---|---|
OPENBAO_ADDR |
IP/DNS the Kanrisha tape host uses to reach this vault (goes in the cert SAN; you point the daemon at https://$OPENBAO_ADDR:8200). |
OPENBAO_BIND |
Host interface the API publishes on. Left at the default, deploy.sh narrows it to OPENBAO_ADDR when that is an IP (a published port bypasses the INPUT firewall, so this bind is the real restriction — see Exposure). It must be an address this host actually holds — deploy.sh checks that before writing anything and refuses otherwise (SKIP_BIND_CHECK=1 to override). |
OPENBAO_TLS_SANS |
Extra SANs beyond OPENBAO_ADDR + loopback (which deploy.sh always adds). Read from the environment at deploy time — export it before running deploy.sh. |
OPENBAO_TAG |
OpenBao image tag (pin it). |
OPENBAO_HSM_PIN |
Only for PKCS#11 auto-unseal. Leave blank for manual unseal. |
See .env.example for the full list.
Deploy
./automations.sh # Deploy on this host → deploy: openbao
# or, non-interactive:
OPENBAO_ADDR=10.0.0.10 SKIP_PROMPTS=1 bash deployments/openbao/deploy.sh
deploy.sh installs Docker, generates a self-signed TLS cert (if none present),
seeds .env, disables swap (keeping key material off disk), narrows the API
bind to the LAN IP,
opens 8200/tcp, aligns file/volume ownership to the container's UID, and brings
the stack up. OpenBao starts sealed — initialise + unseal once (do this
immediately; an uninitialised vault reachable on the LAN can be init'd by anyone
who connects):
docker compose exec -e BAO_ADDR=https://127.0.0.1:8200 openbao \
bao operator init -tls-skip-verify # prints 5 unseal keys + root token
docker compose exec -e BAO_ADDR=https://127.0.0.1:8200 openbao \
bao operator unseal -tls-skip-verify <key> # x3, three different keys
Store the unseal keys + root token out of band — ideally age-encrypted with
your backup recipient (globals/age-pubkey.txt), never on this host.
Re-running after a wrong address
deploy.sh is idempotent, but two things it writes are deliberately sticky:
.env (never overwritten) and tls/tls.{crt,key} (never regenerated over an
existing pair). So passing a corrected OPENBAO_ADDR to a re-run does not move
the vault — the cert keeps the old SAN, and the old .env usually still decides
the bind. Which OPENBAO_BIND wins follows Compose's own precedence:
How OPENBAO_BIND is set |
What Compose uses |
|---|---|
Exported into deploy.sh's environment (OPENBAO_BIND=… bash deploy.sh, or answering the bind prompt — automations.sh passes answers via env VAR=…) |
the environment value; .env is ignored for this run, so a later hand-run docker compose up -d can bind somewhere else |
Derived by deploy.sh (bind prompt left blank → narrowed to OPENBAO_ADDR) |
the .env value, since that assignment is never exported |
| Absent from both | 0.0.0.0 — all interfaces |
deploy.sh warns on each of those mismatches. To actually change the address:
cd /srv/openbao
docker compose down # keeps the raft volume
sed -i 's/OLD_IP/NEW_IP/g' .env # OPENBAO_ADDR + OPENBAO_BIND
rm -f tls/tls.crt tls/tls.key # force a new SAN
# then re-run deploy.sh with the corrected OPENBAO_ADDR
A bind address the host does not hold is caught up front; without that check
Docker fails the up with cannot assign requested address only after the
bad value is already in .env and the cert.
TLS
-
Self-signed (default):
deploy.shrunsgen-tls.shto create./tls/tls.{crt,key}withOPENBAO_ADDRin the SAN. Handtls.crtto the Kanrisha daemon as[encryption.openbao].ca_cert. -
Smallstep CA over ACME (option): issue a cert from your
step-caand drop it in./tlsinstead —gen-tls.shthen no-ops. e.g. with thestepclient:step ca certificate "$OPENBAO_ADDR" ./tls/tls.crt ./tls/tls.key \ --provisioner acme --acme https://ca.lan/acme/acme/directory # renew on a timer: step ca renew --daemon ./tls/tls.crt ./tls/tls.keyGive the Kanrisha daemon your Smallstep root as
ca_cert(then it trusts the vault without-tls-skip-verify).
Auto-unseal (optional)
Default is manual unseal after each restart. For hands-off restarts, enable the
seal "pkcs11" stanza in config.hcl, mount the PKCS#11 module +
device into the openbao service, and set OPENBAO_HSM_PIN in .env.
Bootstrap for Kanrisha
Once unsealed, enable KV v2 + AppRole and seed the domain key with the Kanrisha
bootstrap script (from the Kanrisha repo, deploy/openbao/bootstrap.sh). It calls
the bao CLI directly, so run it from a host that has bao (the Kanrisha
host or your workstation) pointed at this vault — this vault host only ships
Docker. Copy tls.crt to that host first and pass it as BAO_CACERT:
BAO_ADDR=https://$OPENBAO_ADDR:8200 BAO_CACERT=/path/to/openbao-ca.crt \
BAO_TOKEN=<root> bash bootstrap.sh
It enables the kanrisha-tape KV-v2 mount (with effectively-unlimited
max_versions so a key rotation never orphans old tapes), creates the AppRole +
policy, seeds the domain key, and prints the [encryption.openbao] block for the
Kanrisha config. Point the daemon at address = "https://$OPENBAO_ADDR:8200".
Exposure
This is a secrets store on the LAN, not a public service. Two things to know:
- A Docker-published port is DNAT'd and bypasses the host
INPUTfirewall. A source-restricting rule onINPUTdoes not gate:8200. The real interface restriction is the publish bind:deploy.shdefaultsOPENBAO_BINDtoOPENBAO_ADDRwhen that is an IP, so the API listens only on that LAN IP. To restrict by source host, use aFORWARD/DOCKER-USERrule or network segmentation, notINPUT. - On a Proxmox guest, the per-VM firewall filters before the guest sees
anything. If the VM's NIC has
firewall=1and the datacenter firewall is enabled, a Docker-published port needs its ownIN ACCEPT -p tcp -dport 8200rule in/etc/pve/firewall/<vmid>.fw. SSH working does not prove the path — it only proves there is a rule for 22. The give-away is that everything inside the guest looks perfect (curlto the bind address answers, the DNAT andFORWARDjumps are present) whiletcpdump -ni eth0 'tcp port 8200'captures zero packets during a failed connection. - mTLS (optional): to require the tape host to present a client cert, enable
the
tls_require_and_verify_client_certstanza inconfig.hcland issue the tape host a client cert from the same CA.
And initialise the vault immediately after deploy.sh — an uninitialised
vault reachable on the LAN can be bao operator init'd by anyone who connects,
handing them the root token and unseal keys.
Reaching it without LAN access
The API publishes on OPENBAO_BIND only, so a browser on another subnet (or
behind a firewall you do not control) cannot reach it. Tunnel over SSH instead
of widening the publish — the generated cert already carries DNS:localhost
and IP:127.0.0.1 in its SANs, so it validates as-is:
ssh -L 8200:<bind-addr>:8200 root@<host>
# then browse https://localhost:8200
This is also the better way to do the first operator init: the unseal keys and
root token are shown in your browser instead of a root shell's scrollback.
Updating
update.sh is installed alongside the stack. It is not run by deploy.sh,
because upgrading a live vault seals it — that is an operator's decision, not a
deploy step.
cd /srv/openbao
bash update.sh check # declared / running / latest + seal state
BAO_TOKEN=<token> bash update.sh update # snapshot, then upgrade
Unlike the copyparty and ergo updaters in this repo, this one will not update
on a schedule by default. Those services come back by themselves; OpenBao
comes back sealed, so an unattended 03:00 update would take the vault offline
until someone arrives with three unseal keys. update.sh install therefore
schedules a daily check (UPDATE_POLICY=notify). UPDATE_POLICY=auto opts
into unattended updates and is still refused unless a seal stanza is
configured — only auto-unseal makes the vault come back on its own.
Before it changes anything, update requires that the container is running, the
vault is unsealed (a sealed vault cannot produce a snapshot, so there would
be no rollback plan), and a BAO_TOKEN with sys/storage/raft/snapshot — the
root token works. It writes the snapshot to /var/backups/openbao at 0600,
verifies it is a valid non-empty gzip archive, and refuses to continue if it is
not. Copy it off the host: it is the rollback plan, and OpenBao's upgrade
guide is explicit that reverting the image alone does not roll back the data
store. SKIP_SNAPSHOT=1 exists and says loudly what you are giving up.
If the pull or the start fails, the OPENBAO_TAG pin is rolled back and the
previous version is started again — still sealed.
update.sh also refuses to cross into 2.7.x while an active built-in
seal "pkcs11" stanza is present: that stanza is removed in 2.7.0, not merely
deprecated, so the vault would come up with no way to unseal at all. Migrate to
the external plugin "kms" "pkcs11" first.
Backup / DR
The vault is the sole recovery path for encrypted tapes — back it up:
Snapshot save/restore are token-gated (sys/storage/raft/snapshot is
sudo-capable) — pass a token that has that path (the root token works, or mint a
dedicated snapshot-policy token). The container has no ambient token, so supply
it via -e BAO_TOKEN.
# Consistent raft snapshot (safe while running); write it to a scratch path, NOT
# into the live raft dir:
docker compose exec -T -e BAO_TOKEN=<token> openbao \
bao operator raft snapshot save -address=https://127.0.0.1:8200 -tls-skip-verify /tmp/openbao.snap
# then stream the RAW file off-box, age-encrypted. Use `exec -T ... cat`, not
# `compose cp openbao:… -` (which emits a TAR wrapper that won't restore):
docker compose exec -T openbao cat /tmp/openbao.snap | \
age -r "$(cat /path/to/globals/age-pubkey.txt)" > "openbao-$(date +%F).snap.age"
Restore (into a fresh, unsealed vault): decrypt, copy the raw .snap in, and
apply it (also token-gated):
age -d -i ~/.age/key.txt openbao-DATE.snap.age > openbao.snap
docker compose cp openbao.snap openbao:/tmp/openbao.snap
docker compose exec -T -e BAO_TOKEN=<token> openbao \
bao operator raft snapshot restore -address=https://127.0.0.1:8200 -tls-skip-verify /tmp/openbao.snap
Snapshots do not contain the unseal keys or root token — you still need those to unseal a restored vault, which is why they are stored separately.