Services/OpenBao
As of: 2026-07-05 — migrated from `services/OPENBAO.md`
OpenBao Secret Store
Self-hosted Vault-API-compatible secret store. The canonical record for credentials used by Claude Code (MCP), shell scripts, and n8n workflows.
Vaultwarden remains the human-facing store. OpenBao is the machine-facing one. They mirror each other; on rotation, update both.
Container & Files
| Item | Value |
|---|---|
| Container | openbao
|
| Compose | /library/openbao-app/docker-compose.yml
|
| Config | /library/openbao-app/config/openbao.hcl
|
| Storage | /library/openbao-app/data/ (Raft)
|
| Init output | /library/openbao-app/.init-output.json (chmod 600 — root token + unseal key)
|
| Scripts token | /library/openbao-app/.scripts-token (chmod 600 — used by bao-get.sh)
|
| Claude token | /library/openbao-app/.claude-token (chmod 600 — also embedded in ~/.claude.json)
|
| Auto-unseal | /usr/local/bin/openbao-unseal.sh + openbao-unseal.service (systemd)
|
| Helper script | /usr/local/bin/bao-get.sh
|
| n8n cred sync | /library/openbao-app/sync-n8n-creds.py (timer: sync-n8n-creds.timer)
|
| MCP binary | /usr/local/bin/vault-mcp-server (HashiCorp v0.2.0)
|
| VC sources | scripts/openbao/ in this repo — see scripts/openbao/README.md for deploy
|
<syntaxhighlight lang="bash"> cd /library/openbao-app && docker compose up -d docker logs --tail=50 openbao 2>&1 | grep -v "^chown\|Could not chown" </syntaxhighlight>
Ports & Endpoints
| Port | Purpose |
|---|---|
| 8200 | API + UI (HTTP, no TLS — internal only) |
| 8201 | Cluster (Raft consensus, single-node here) |
| Endpoint | URL |
|---|---|
| API (host) | http://localhost:8200
|
| API (LAN) | http://192.168.0.109:8200
|
| API (Tailscale) | http://100.64.207.155:8200
|
| API (n8n container) | http://172.29.0.1:8200
|
| Web UI | http://192.168.0.109:8200/ui
|
Not exposed publicly — no VPS nginx route, intentionally. UI is LAN/Tailscale only.
Credentials
All in Vaultwarden → "Stronghold Infrastructure" folder.
| Vaultwarden item | Contents |
|---|---|
| OpenBao Unseal | root token (s.TqAL...) + unseal key (dHIJ...=) in notes
|
| OpenBao Tokens | claude-mcp token (login.password) + scripts-n8n token (custom field) |
The init-output.json on stronghold has the same data — that's how openbao-unseal.service reads the unseal key at boot.
Reading Secrets
From Claude Code (MCP)
Just ask: "fetch secret/ntfy from vault" or "what's the mxroute api-key?" — the vault MCP server (16 tools) handles it.
From shell scripts
<syntaxhighlight lang="bash"> bao-get.sh PATH FIELD
- e.g.
bao-get.sh ntfy token bao-get.sh mxroute api-key bao-get.sh authentik api-token </syntaxhighlight>
Reads from /library/openbao-app/.scripts-token. Outputs the field value to stdout, no newline.
From curl (any host with LAN access)
<syntaxhighlight lang="bash"> TOKEN=$(cat /library/openbao-app/.scripts-token) curl -sf http://localhost:8200/v1/secret/data/PATH \
-H "X-Vault-Token: $TOKEN" \ | jq -r '.data.data.FIELD'
</syntaxhighlight>
From n8n
HTTP Request node:
- Method:
GET - URL:
http://172.29.0.1:8200/v1/secret/data/PATH - Headers:
X-Vault-Token: <scripts-n8n token> - Response field:
data.data.<field>
Or in a Code node: <syntaxhighlight lang="javascript"> const resp = await this.helpers.httpRequest({
method: 'GET', url: 'http://172.29.0.1:8200/v1/secret/data/mxroute', headers: { 'X-Vault-Token': '<scripts-n8n token>' },
}); const apiKey = resp.data.data['api-key']; </syntaxhighlight>
Recommended: store the scripts-n8n token as an httpHeaderAuth credential in n8n (header name X-Vault-Token) so workflows reference it by credential ID.
Writing Secrets (via root token)
<syntaxhighlight lang="bash"> ROOT_TOKEN=$(jq -r .root_token /library/openbao-app/.init-output.json)
- kv put REPLACES the entire record — pass all fields you want kept
docker exec -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN="$ROOT_TOKEN" openbao \
bao kv put secret/PATH field1=value1 field2=value2
</syntaxhighlight>
For multiline values (private keys, certs), use the API directly:
<syntaxhighlight lang="bash"> python3 - << 'PY' import json, urllib.request payload = {"data": {"private-key": open('/path/to/key').read()}} req = urllib.request.Request(
"http://localhost:8200/v1/secret/data/PATH", method="POST", data=json.dumps(payload).encode(), headers={"X-Vault-Token": "<ROOT_TOKEN>", "Content-Type": "application/json"})
print(urllib.request.urlopen(req).read()) PY </syntaxhighlight>
Secret Inventory
| Path | Fields | Source-of-truth status |
|---|---|---|
secret/n8n
|
api-key
|
OpenBao canonical; n8n's own auth is separate |
secret/mxroute
|
server, username, api-key
|
OpenBao canonical |
secret/homeassistant
|
token, host, port
|
OpenBao canonical; mirrored in n8n credential R9LGxX... and 4RowxM...
|
secret/ntfy
|
token
|
OpenBao canonical; mirrored in n8n credential w0gXpM...
|
secret/authentik
|
api-token
|
OpenBao canonical |
secret/matrix
|
zordon-token
|
OpenBao canonical; mirrored in n8n credential x5tv5a...
|
secret/stalwart
|
admin-password
|
OpenBao canonical |
secret/immich
|
admin-api-key, user-api-key
|
OpenBao canonical |
secret/bitwarden
|
client-id, client-secret, master-password
|
For bw CLI; also in ~/.config/claude/vaultwarden.env
|
secret/openai
|
api-key, bearer-header
|
OpenBao canonical; mirrored in n8n credentials NIQrbn..., XB0V2U...
|
secret/anthropic
|
api-key
|
OpenBao canonical; mirrored in n8n credential bEPlqP...
|
secret/audiobookshelf
|
bearer-header
|
OpenBao canonical; mirrored in n8n credential 5MCzns...
|
secret/ssh-stronghold
|
username, host, port, private-key
|
OpenBao canonical; mirrored in n8n credential 2ugS1i...
|
To list paths live: bao-get.sh doesn't support list — use the UI or:
<syntaxhighlight lang="bash">
ROOT_TOKEN=$(jq -r .root_token /library/openbao-app/.init-output.json)
docker exec -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN="$ROOT_TOKEN" openbao bao kv list secret/
</syntaxhighlight>
Operational
Restart
OpenBao re-seals on every restart. The systemd service handles auto-unseal:
<syntaxhighlight lang="bash"> cd /library/openbao-app && docker compose restart sudo systemctl start openbao-unseal.service docker exec -e BAO_ADDR=http://127.0.0.1:8200 openbao bao status | grep Sealed
- Expected: Sealed: false
</syntaxhighlight>
Manual unseal (if the systemd unit fails): <syntaxhighlight lang="bash"> /usr/local/bin/openbao-unseal.sh </syntaxhighlight>
Backups
/library/openbao-app/ is mounted into restic-backup as /data/openbao:ro and included in the nightly Hetzner restic backup. Both the Raft data and .init-output.json are backed up — restoring requires both (the unseal key would be lost otherwise).
n8n credential sync
n8n credentials are mirrored from OpenBao by /library/openbao-app/sync-n8n-creds.py. The script reads each managed credential's spec (n8n ID + OpenBao path + field mapping) from a MAPPINGS table, diffs against n8n export:credentials --decrypted, and runs n8n import:credentials only on drift. Match is by credential ID — never creates new credentials. Posts to #notifications:wilsoz.com on change.
<syntaxhighlight lang="bash"> /library/openbao-app/sync-n8n-creds.py --dry-run # preview drift /library/openbao-app/sync-n8n-creds.py # sync all /library/openbao-app/sync-n8n-creds.py --only ntfy_auth # one mapping /library/openbao-app/sync-n8n-creds.py --quiet # cron-friendly </syntaxhighlight>
Daily timer: sync-n8n-creds.timer (04:30 + 0–5 min jitter). Logs in journalctl -u sync-n8n-creds.service.
Rotation flow: update OpenBao → next nightly sync (or manual run) propagates to n8n. No workflow edits.
Adding a new mapping: append to MAPPINGS in the script. Must specify n8n_id (existing credential — create the credential in n8n UI first), n8n_name, n8n_type, bao_path, and a build lambda that returns the n8n data dict from the OpenBao field dict.
Token rotation
<syntaxhighlight lang="bash"> ROOT_TOKEN=$(jq -r .root_token /library/openbao-app/.init-output.json)
- Revoke old token
docker exec -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN="$ROOT_TOKEN" openbao \
bao token revoke <OLD_TOKEN>
- Mint new with same policy
docker exec -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN="$ROOT_TOKEN" openbao \
bao token create -policy=readonly-secrets -no-default-policy -ttl=0 -display-name="..."
</syntaxhighlight>
Update ~/.claude.json (claude-mcp token) and /library/openbao-app/.scripts-token (scripts-n8n token) to the new values, then update Vaultwarden.
Adding a new secret path
<syntaxhighlight lang="bash"> ROOT_TOKEN=$(jq -r .root_token /library/openbao-app/.init-output.json) docker exec -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN="$ROOT_TOKEN" openbao \
bao kv put secret/NEWPATH field1=value1
</syntaxhighlight>
The readonly-secrets policy uses path "secret/data/*" so new paths are immediately readable by claude-mcp and scripts-n8n tokens — no policy update needed.
Gotchas (hard-won)
- Container runs as
user: "1000:1000"(mnw). Without this, files on/library/openbao-app/dataend up owned by UID 100 (messagebuson host) which is confusing. - Must set
SKIP_CHOWN=1andSKIP_SETCAP=1env vars. Without them, the entrypoint'schown /openbao/logsfails underset -eand the container restart-loops. docker execto bao CLI needs-e BAO_ADDR=http://127.0.0.1:8200. The CLI defaults to https else and fails with "server gave HTTP response to HTTPS client".- Sealed on every restart. Raft + Shamir means the box re-seals on container start. Auto-unseal via the systemd service. Don't delete
.init-output.json. bao kv putreplaces the whole record. Always pass all fields you want kept, not just the changed one.- Multi-line values fail with
bao kv putshell escaping. Use the HTTP API (curl/python urllib) directly for private keys, certs, etc. - vault-mcp-server release binaries. GitHub releases page shows zero assets — download from
releases.hashicorp.cominstead (https://releases.hashicorp.com/vault-mcp-server/0.2.0/vault-mcp-server_0.2.0_linux_amd64.zip). bao kv put field=@/path/to/filereads from container fs, not host. Either copy the file into the container first or use the HTTP API.
Related
- Setup plan (preserved for archaeology):
plans/openbao-setup.md - Memory note:
~/.claude/projects/.../memory/project_openbao.md - CLAUDE.md "OpenBao Secret Store" section