CS

OpenBao in Docker Compose — Init, Unseal and AppRole

Running self-hosted OpenBao in Docker Compose: init and unseal, AppRole, KV secrets, an audit log, tracing a 403, rekey, backups, upgrades and moving from Vault.

What this covers

Operating self-hosted OpenBao (the open-source fork of HashiCorp Vault) as a Docker Compose service on a single host: the first initialisation, unsealing after every restart and its typical errors, enabling secrets engines, letting an application log in through AppRole instead of a static token, storing and reading KV secrets, replacing the unseal keys (rekey), a new root token, backup and restore, and finally rebuilding the store when the keys are lost. This is the operational layer — policy design and the integration of a specific client library are covered by the OpenBao documentation .

When to use it

  • Standing up OpenBao in a Compose stack next to an application that reads its secrets from it
  • After a host or container restart, when the application reports it cannot read a secret (the store is sealed)
  • When unseal fails, or when you want to replace the unseal keys or the root token
  • Adding a new secret, or a new application with its own AppRole and policy
  • Before an upgrade or a migration — backing up the storage

The Compose service

openbao:
  image: openbao/openbao:2.4.1
  command: server -config=/openbao/config/openbao.hcl
  environment:
    BAO_ADDR: http://127.0.0.1:8200   # otherwise the CLI inside the container tries https
  volumes:
    - ./openbao/config:/openbao/config:ro
    - openbao-data:/openbao/file
  healthcheck:
    # healthy = the API answers; a sealed store is not a container failure
    test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8200/v1/sys/health?sealedcode=200&uninitcode=200"]
    interval: 10s
  restart: unless-stopped
  # no ports: — clients come in over the internal Compose network

volumes:
  openbao-data:      # a named volume, or the data goes with the container
# openbao/config/openbao.hcl
storage "file" {
  path = "/openbao/file"
}

listener "tcp" {
  address     = "0.0.0.0:8200"
  tls_disable = true   # only on a private Compose network; always TLS to the outside
}

api_addr = "http://openbao:8200"
ui       = false

Every command below runs from the directory holding the Compose file, as docker compose exec openbao bao … — the port does not need to be published to the host at all.

Tokens and keys in the terminal

Most trouble with hands-on operation is not in OpenBao but in how a token or key gets into the command. read -rs is the right tool (the value stays out of the history and off the screen), but it has three properties that are easy to forget:

  • It prints nothing — no prompt, no asterisks. The cursor just waits for you to paste the value and press Enter.
  • Run it on its own, never as part of a pasted block. When several lines are pasted into the terminal at once, read swallows the next pasted line (or an empty one) as its input, and the variable ends up without the token — silently. Every call after that fails with 403 permission denied.
  • Paste with the right mouse button or Ctrl+Shift+V (Windows Terminal, WSL); a hidden input gives no sign that the paste happened.

So strip invisible characters right after reading the value, and check its length (the table gives typical lengths; for a token, bao token lookup is the real check):

read -rs BAO_TOKEN
BAO_TOKEN=$(printf %s "$BAO_TOKEN" | tr -cd 'A-Za-z0-9._-'); export BAO_TOKEN; echo "length: ${#BAO_TOKEN}"
ValueExpected length
root token, OpenBao 2.x (s.…; other tokens differ)26
unseal key, base6444 (no trailing =)
unseal key, hex66
AppRole role_id / secret_id36 (UUID)

A length of 0 means nothing was read. When the hidden input won’t cooperate, there are two fallbacks:

read -rp "Token: " BAO_TOKEN     # visibly…
clear; export BAO_TOKEN; echo "length: ${#BAO_TOKEN}"   # …then clear the screen right away

or a temporary file, which avoids pasting into the terminal altogether:

install -m 600 /dev/null ~/.bao-token && nano ~/.bao-token
export BAO_TOKEN=$(tr -cd 'A-Za-z0-9._-' < ~/.bao-token); echo "length: ${#BAO_TOKEN}"
rm ~/.bao-token                  # when you are done

The variable lives only in the terminal window where you read it — in another window it is empty.

First start: init

docker compose up -d openbao
docker compose exec openbao bao status      # Initialized false, Sealed true
docker compose exec openbao bao operator init -key-shares=5 -key-threshold=3

init prints five unseal keys and the root token once. Move them to a password manager right away — not to a file in the repository, not to .env, not to a chat or a ticket. The keys are base64 — 44 characters with no trailing = (a share is 33 bytes, so there is no padding); the hex form is 66 characters. With -format=json you get both forms, unseal_keys_b64 and unseal_keys_hex; store and use only one of them.

Initialise as soon as it starts: sys/init needs no authentication, so anyone on the same network could initialise the server first and keep the keys. If bao status says Initialized true on a server you did not initialise, do not unseal it — remove the volume and start again.

A single operator may choose -key-shares=1 -key-threshold=1, accepting that one key then opens everything.

Unseal — after init and after every restart

docker compose exec openbao bao operator unseal    # 3× with different keys
docker compose exec openbao bao status             # Sealed false

The command prompts for the key without echoing it, so the key stays out of the shell history. bao status exits 0 (unsealed), 2 (sealed) and 1 (error) — handy for scripts and monitoring:

docker compose exec -T openbao bao status >/dev/null; echo $?

To discard an unseal in progress (after a wrong key, for example):

docker compose exec openbao bao operator unseal -reset

When pasting into the hidden prompt keeps failing, pass the key as an argument — through a variable, so it stays out of the history (it is visible in ps for a moment, which is acceptable on a single-user host):

read -rs K
K=$(printf %s "$K" | tr -cd 'A-Za-z0-9+/='); echo "length: ${#K}"; docker compose exec openbao bao operator unseal "$K"; unset K
ErrorWhat it means
'key' must be a valid hex or base64 stringWhat you pasted is not a key — most often the root token (it starts with s.), or a whole output line including Unseal Key 2: . The progress is kept; just paste the right key.
failed to compute combined key: all parts must be the same lengthOne key has a different length — mangled, a stray space, or another format (b64 vs. hex). -reset and start over.
cipher: message authentication failed after the third keyThe keys come from another init (the store was created again since).
The client gets 503 / Vault is sealedNobody unsealed the store after a restart.
403 permission denied on every command with -e BAO_TOKENBAO_TOKEN is empty or from another init — check its length (see Tokens and keys in the terminal ).

Mounts: KV, transit, AppRole

Once, after the first unseal, with the root token read into BAO_TOKEN as in the previous section . First check it really is the root token of this store:

docker compose exec -T -e BAO_TOKEN openbao bao token lookup | grep policies    # [root]
docker compose exec -e BAO_TOKEN openbao bao secrets enable -path=secret kv-v2
docker compose exec -e BAO_TOKEN openbao bao secrets enable transit   # optional: only if something encrypts through it
docker compose exec -e BAO_TOKEN openbao bao auth enable approle
docker compose exec -e BAO_TOKEN openbao bao secrets list
docker compose exec -e BAO_TOKEN openbao bao auth list

AppRole for an application

The application should not hold a static token. It logs in with a role_id + secret_id pair and receives a short-lived token that can do only what its policy allows.

# openbao/policies/myapp.hcl — read its own secrets, nothing else
path "secret/data/myapp/*" {
  capabilities = ["read"]
}
# the policy and the role (with the root token from the previous step)
docker compose exec -T -e BAO_TOKEN openbao bao policy write myapp - < openbao/policies/myapp.hcl
docker compose exec -e BAO_TOKEN openbao bao write auth/approle/role/myapp \
  token_policies=myapp token_ttl=5m token_max_ttl=15m

# role_id (not secret) — printed, copy it into .env
docker compose exec -e BAO_TOKEN openbao bao read -field=role_id auth/approle/role/myapp/role-id

# secret_id (secret) — straight into .env, never to the terminal (adjust the variable name)
touch .env && chmod 600 .env
printf 'MYAPP_SECRET_ID=%s\n' "$(docker compose exec -T -e BAO_TOKEN openbao \
  bao write -f -field=secret_id auth/approle/role/myapp/secret-id)" >> .env

Both values go into the application’s .env (gitignored); then recreate the application container (docker compose up -d app) so it picks them up. To check the login works — this prints only the TTL, not the token:

read -rs SECRET_ID      # on its own, then paste the secret_id and press Enter
printf %s "$SECRET_ID" | docker compose exec -T openbao \
  bao write -field=token_duration auth/approle/login role_id=<role_id> secret_id=-
unset SECRET_ID

Rotating the secret_id: issue a new one, replace it in .env, recreate the container, and destroy the old one by its accessor:

docker compose exec -e BAO_TOKEN openbao bao list auth/approle/role/myapp/secret-id
docker compose exec -e BAO_TOKEN openbao bao write \
  auth/approle/role/myapp/secret-id-accessor/destroy secret_id_accessor=<accessor>

What a token may do

When debugging policies, ask the server instead of reading HCL:

docker compose exec -e BAO_TOKEN openbao bao policy read myapp
docker compose exec -e BAO_TOKEN openbao bao token capabilities secret/data/myapp/api_token   # what your token may do
docker compose exec -e BAO_TOKEN openbao bao token capabilities -accessor <accessor> secret/data/myapp/api_token   # someone else's token, without its value

Mind the KV v2 prefix: the policy must name secret/data/… (reading the value) and secret/metadata/… (listing and versions), even though the CLI with -mount=secret writes just myapp/….

KV secrets

Send the value through stdin (value=-), so it shows up neither in the history nor in the process list:

read -rs SECRET          # on its own, then paste the value and press Enter
printf %s "$SECRET" | docker compose exec -T -e BAO_TOKEN openbao \
  bao kv put -mount=secret myapp/api_token value=-
unset SECRET

docker compose exec -e BAO_TOKEN openbao bao kv list -mount=secret myapp
docker compose exec -e BAO_TOKEN openbao bao kv metadata get -mount=secret myapp/api_token   # versions, no value
docker compose exec -T -e BAO_TOKEN openbao bao kv get -mount=secret -field=value myapp/api_token | wc -c   # length instead of value
docker compose exec -e BAO_TOKEN openbao bao kv rollback -mount=secret -version=1 myapp/api_token

Store the secret before you switch the application to reading it from OpenBao. Once it reads it from there, it should have no second source — see fail closed below.

Audit log

A secrets store without an audit log cannot answer “who read this secret, and when”. Since OpenBao 2.4 audit devices are declared in the server configuration; enabling one through the API (bao audit enable) is refused since 2.3.2 unless the configuration sets unsafe_allow_api_audit_creation — a file device can write anywhere.

# openbao/config/openbao.hcl
audit "file" "file" {
  description = "Audit log to a file"
  options {
    file_path = "/openbao/logs/audit.log"
  }
}
# in the openbao service in Compose
    volumes:
      - openbao-logs:/openbao/logs
# and openbao-logs: under the top-level volumes:

A new named volume is created owned by root, and the OpenBao process does not run as root, so give it the volume once before the first start — otherwise it cannot open audit.log and, because of the rule below, refuses every request:

docker compose run --rm --no-deps --user root --entrypoint chown openbao -R openbao:openbao /openbao/logs
docker compose up -d openbao                              # restart → unseal again
docker compose exec -e BAO_TOKEN openbao bao audit list
docker compose exec openbao tail -n 3 /openbao/logs/audit.log

Values in the entries are HMACs, not readable secrets. Two operational points: when OpenBao cannot write to any audit device, it refuses requests (a full disk is an outage), and the log grows without bound — give it logrotate, or ship it to central logging (file_path = "stdout" and collection through the Docker logging driver).

The application gets a 403: tracing the cause

A fail-closed application only says “the secret cannot be read”. There are several causes and they form a chain, so walk it in order from OpenBao to the application (app is the application’s service, MYAPP_* its variables):

# 1. OpenBao is initialised and unsealed
docker compose exec openbao bao status | grep -E 'Initialized|Sealed'

# 2. The application reaches it — from its container, not from the host
docker compose exec app curl -s -o /dev/null -w '%{http_code}\n' http://openbao:8200/v1/sys/health
#    200 ok · 503 sealed · 501 not initialised · 000 another network or DNS
#    (empty or 000 may also mean the image has no curl — try: docker compose exec app wget -qO- http://openbao:8200/v1/sys/health)

# 3. The application container has the AppRole in its environment (lengths, not values; both 36)
docker compose exec app sh -c 'echo role=${#MYAPP_ROLE_ID} secret=${#MYAPP_SECRET_ID}'

# 4. The container runs the current configuration (Recreate = an older one is running)
docker compose up -d --dry-run 2>&1 | grep -i recreate

# 5. The AppRole login works (see AppRole above)
# 6. The secret exists and has the expected length
docker compose exec -T -e BAO_TOKEN openbao bao kv get -mount=secret -field=value myapp/api_token | wc -c

# 7. The role's policy may read it
docker compose exec -e BAO_TOKEN openbao bao policy read myapp

Steps 3 and 4 are the cause more often than you would expect: values in .env do not reach a running container by themselves — it takes docker compose up -d app.

The root token: revoke it, and make a new one when needed

The root token is for setting things up. Once the roles and secrets exist, revoke it:

docker compose exec -e BAO_TOKEN openbao bao token revoke -self
unset BAO_TOKEN

A new root token is made from the unseal keys:

docker compose exec openbao bao operator generate-root -init   # prints the OTP and the nonce
docker compose exec openbao bao operator generate-root         # 3× with unseal keys → encoded token
docker compose exec openbao bao operator generate-root -decode=<encoded_token> -otp=<otp>

Rekey: a new set of unseal keys

When you want to replace the keys (someone who held one has left, the keys were stored carelessly), you need a threshold of the current keys:

docker compose exec openbao bao operator rekey -init -key-shares=5 -key-threshold=3
docker compose exec openbao bao operator rekey          # 3× with the old keys → 5 new ones
docker compose exec openbao bao operator rekey -status
docker compose exec openbao bao operator rekey -cancel  # discard a rekey in progress

The new keys are printed once, just as with init. The old ones stop working when it completes.

Backup and restore (file storage)

Back up file storage with the container stopped, so the copy is consistent. The backup is encrypted — useless without the unseal keys, the whole store with them:

docker compose stop openbao
docker run --rm -v <project>_openbao-data:/data:ro -v ~/backups/openbao:/backup alpine \
  tar czf /backup/openbao-$(date -u +%Y%m%dT%H%M%SZ).tgz -C /data .
docker compose start openbao      # and unseal again

Restoring into an empty volume:

docker compose stop openbao
docker run --rm -v <project>_openbao-data:/data -v ~/backups/openbao:/backup alpine \
  sh -c 'find /data -mindepth 1 -delete && tar xzf /backup/<file>.tgz -C /data'
docker compose start openbao      # unseal with the keys from the time of the backup

With Raft storage instead of file, back up with a snapshot while it runs: bao operator raft snapshot save /tmp/bao.snap.

Upgrading OpenBao

On a single host an upgrade is swapping the image over the same data:

# 1. a backup taken with OpenBao stopped (see Backup and restore) — it is also your rollback: once the new version
#    has migrated the data, the old image may no longer start on it
# 2. a new image version in Compose, e.g. openbao/openbao:2.4.1 → a newer 2.4.x
docker compose pull openbao
docker compose up -d openbao
docker compose exec openbao bao operator unseal      # 3×, the new process starts sealed
docker compose exec openbao bao status | grep -E 'Version|Sealed'

Read the release notes before moving to a higher minor version — behaviour that breaks operations does change between versions (for example, since 2.3.2 audit devices can no longer be enabled through the API). Go up step by step, not across several minor versions at once. To roll back, stop OpenBao, restore the backup into the volume (see Backup and restore), put the old image back and unseal with the keys from the time of the backup.

Moving from HashiCorp Vault

OpenBao forked from Vault 1.14, so the CLI and the API are compatible, and most scripts work after replacing vault → bao and VAULT_* → BAO_*. Moving the data takes care, though. The official guide was tested only with Vault Community 1.14.1 (Raft, Shamir) and says explicitly that newer Vault versions are untested and storage compatibility is not guaranteed. The main points:

  • Upgrade a Vault older than 1.14.1 to 1.14.1 first; Vault Enterprise is not tested.
  • Before migrating, take a backup (Raft snapshot) and list the mounts (vault secrets list, vault auth list) — mounts whose plugin OpenBao lacks are skipped at startup and have to be disabled.
  • Remove disable_mlock from the configuration (OpenBao has not used mlock since 2.0).
  • New tokens have a different format (s., b., r.); clients that check the token format may break.

For a small single-host deployment it is often simpler to start a clean OpenBao and store the secrets again (kv get → kv put) than to migrate the storage.

Rebuilding the store (the keys are lost)

Without a threshold of valid unseal keys the data is out of reach — no rekey, no new root token. What remains is to wipe the store and start over. Irreversible: every secret, policy, AppRole and transit key goes (and with them the validity of everything signed with those keys). Back up the volume first, in case the keys turn up.

docker compose stop openbao
# back up the volume (see above), then:
docker compose rm -f openbao
docker volume rm <project>_openbao-data
docker compose up -d openbao
docker compose exec openbao bao operator init -key-shares=5 -key-threshold=3
docker compose exec openbao bao operator unseal      # 3×

After a new init everything the application knew is invalid. The steps:

  1. Mounts and policies again (sections above).
  2. The AppRole again — the old role_id and secret_id in the application’s .env no longer work, even if the role has the same name.
  3. Store the secrets again — before the application starts reading, or fail closed refuses.
  4. Recreate the application container (docker compose up -d app) so it picks up the new values, and walk the 403 tracing steps .

Gotchas (single host, Compose)

  • Fail closed is right. An application that reads a secret from OpenBao should not quietly fall back to an environment variable while the store is sealed — that would bring back the very secret OpenBao was meant to replace. The endpoint that needs the secret should refuse (403/503), and the log should name the key that could not be read. Expect that after every host restart the service is down until someone unseals the store, and monitor it (bao status exit code 2).
  • Manual unseal vs. auto-unseal. Without auto-unseal (a cloud KMS, the transit engine of another Vault/OpenBao) unsealing is a manual step for a human. On a single host that is the price of the key never lying on disk next to the data.
  • The container healthcheck. /v1/sys/health returns 503 when sealed and 501 before init. Without sealedcode=200&uninitcode=200 a sealed but healthy server looks like a broken container: services with depends_on: condition: service_healthy never start and docker compose up --wait fails.
  • BAO_ADDR inside the container. The CLI tries https://127.0.0.1:8200 by default; with tls_disable set BAO_ADDR=http://127.0.0.1:8200, or bao status fails with a TLS error.
  • $ in .env. Docker Compose interpolates $SOMETHING in .env as a variable — a password or secret containing a dollar sign is silently cut short (warning The "…" variable is not set). Double the dollar ($$) or put the value in single quotes.
  • Non-sensitive output. When checking, print a length or a TTL, never the value (… | wc -c, -field=token_duration); tokens and secrets should not end up in the terminal log or the history (read -rs, value=-).
  • Recreate the container after changing .env. A running container keeps the environment and networks it was created with; docker compose up -d app recreates it, restart does not. A container that has “worked” for weeks may sit outside a network that OpenBao joined later.
  • The reverse proxy after recreating the application. When nginx resolves the upstream name only at startup, it reports could not be resolved and returns 502 after the application is recreated. Recreate the proxy too, or give it resolver 127.0.0.11 with a variable in proxy_pass.
  • mlock. OpenBao has not used mlock since 2.0 — it needs neither cap_add: IPC_LOCK nor disable_mlock (unlike the guides written for Vault).
  • Don’t publish the port. OpenBao is fine on the internal Compose network; if it must be reachable from outside, only through a TLS reverse proxy restricted to specific clients.

Frequently asked questions

What is OpenBao?

An open-source fork of HashiCorp Vault under the Linux Foundation (MPL 2.0 licence). The CLI is bao instead of vault and the environment variables are BAO_ADDR and BAO_TOKEN instead of VAULT_*; the API and most commands are the same.

Why is OpenBao sealed after a restart?

The store’s root encryption key is never written to disk. After every process start an operator has to reconstruct it from the unseal keys (bao operator unseal), unless auto-unseal through a KMS or another Vault/OpenBao is configured. Until then the server hands out no secrets.

What does "failed to compute combined key: all parts must be the same length" mean?

One of the unseal keys you entered has a different length from the others — it was mangled when copied, or the formats are mixed (base64 from the plain init output and hex from unseal_keys_hex). The progress is discarded; run bao operator unseal -reset and enter three keys again in one format.

Can the unseal keys be generated again?

For an existing store only through bao operator rekey, which needs a threshold of the current keys. Without them the data in the store is out of reach, and the only way forward is to wipe the storage and run a fresh bao operator init.

Why does OpenBao answer "403 permission denied" to everything, even with the root token?

Most often because BAO_TOKEN is empty — read -rs shows nothing, and when a whole block of commands is pasted at once it reads an empty line instead of the token. Check the length (echo ${#BAO_TOKEN}); the root token from an OpenBao 2.x init looks like s.… and is 26 characters (other tokens differ in length — bao token lookup is the real check).