Jump to content

Services/OpenBao

From Stronghold Wiki

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

  1. 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:

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)

  1. 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

  1. 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)

  1. 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>
  1. 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)

  1. Container runs as user: "1000:1000" (mnw). Without this, files on /library/openbao-app/data end up owned by UID 100 (messagebus on host) which is confusing.
  2. Must set SKIP_CHOWN=1 and SKIP_SETCAP=1 env vars. Without them, the entrypoint's chown /openbao/logs fails under set -e and the container restart-loops.
  3. docker exec to 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".
  4. 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.
  5. bao kv put replaces the whole record. Always pass all fields you want kept, not just the changed one.
  6. Multi-line values fail with bao kv put shell escaping. Use the HTTP API (curl/python urllib) directly for private keys, certs, etc.
  7. vault-mcp-server release binaries. GitHub releases page shows zero assets — download from releases.hashicorp.com instead (https://releases.hashicorp.com/vault-mcp-server/0.2.0/vault-mcp-server_0.2.0_linux_amd64.zip).
  8. bao kv put field=@/path/to/file reads 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