Local / evaluation only. This is not a production setup. Everything here runs on one machine. Each OpenBao is a single node that unseals itself from a key on the same volume. The engine and the signer share a volume and a network. A production deployment runs the engine and the signer on separate hosts. It uses three-node OpenBao clusters with KMS auto-unseal, and the signer host accepts no inbound connections. See Production architecture.
Your keys never live on Citadel. Three components run on your infrastructure, and each one dials out to the platform. Nothing in the stack publishes a port. The commands and log lines below were captured on a test organization, Northwind Pay. Tokens and pairing codes are masked. Platform addresses are replaced with placeholders: https://api.citadel.example for the API and nats://… for NATS.
To have a coding agent do this bring-up, give it the merchant stack setup skill.
1

Start the stack

Fill .env and run docker compose up.
2

Confirm the engine's keys

TrustedKeys move from Pending to Confirmed.
3

Check the signer pairing

The signer pins the engine’s key.
4

Sign and publish the policy

Use key B, then run check-policy.
5

Derive the first wallet

This proves the whole path.

Before you start

  • Your organization exists and is provisioned: an owner has clicked Provision tenant in Settings → Signer onboarding → Connect the org, and the panel shows a Tenant account org-….
  • You have a join token. It is shown once and looks like ojt_…: either the one Citadel gave you with the organization, or one an owner minted with Mint join token in the same panel. A token can be redeemed once by the engine and once by the signer. It expires after 24 hours.
  • You have the platform’s API URL and NATS URL.
  • Docker with Compose v2.
  • The three images, all with the same <version> tag, pulled from the registry Citadel gives you: <registry>/citadel-openbao:<version> (OpenBao with the Citadel plugin), <registry>/citadel-policy-engine:<version> and <registry>/citadel-signer:<version>.
  • xxd and openssl, or python3, to hash the engine’s public keys in step 2.
  • On a machine that already runs another merchant stack, give this one its own Compose project name (-p merchant-<slug>) and its own .env. Two projects never share volumes.
This stack uses CITADEL_UNSEAL_MODE=demo: 1-of-1 unseal shares kept on the same volume, auto-unseal, no custodians, and a seed without break-glass shares. Anyone who reads the volume owns the seed. Never fund wallets you derive here with real money.

1. Start the stack

Create a directory for this merchant with an .env file:
.env
Next to .env, put compose.yml and engine-policy.hcl from below. bao-init gives its token the policy in /openbao/signer-policy.hcl, which reaches only keys/org. On the engine’s OpenBao, engine-policy.hcl is mounted over that file so the engine’s token can create and use its three keys.
compose.yml
engine-policy.hcl
Every Compose command for this merchant carries the same project name, env file and compose file. Run them from the merchant directory:
On first boot, with no operator involved:
  1. bao-init and bao-init-engine each initialize and unseal their OpenBao, mount multichainsign, and write a token: /state/bao.token for the signer and /engine/bao.token for the engine. bao-init also creates the seed key org.
  2. policy-engine creates engine, request and policy inside openbao-engine. It then redeems the join token with the three public halves and writes /state/state.json and the pairing code.
  3. signer redeems the join token, waits for the pairing code, pins the engine key, checks that multichainsign/keys/org exists, and starts serving on NATS.
The engine’s log shows the join and the three public keys:
The WARN line is expected. The engine refuses everything until step 4.

2. Confirm the engine’s keys

After the join, Settings → Signer onboarding marks Run the policy engine and Connect the org as done. Settings → Policy engine lists three trust anchors as Pending: the engine’s verification key, request key A and policy key B. Check each fingerprint against the engine’s log before you confirm it. The fingerprint is the SHA-256 of the 32 raw key bytes, in groups of four hex characters. The engine, the signer and the console print the same string. For the engine’s verification key, compare the console with the fingerprint field of the engine verification key line above: 0601 cde4 4039 95e2 e3d2 cb84 5cd4 8918 df78 073c 3003 5f64 076a 04f4 ba4f 0d72. The engine logs keys A and B as hex only. Hash their raw bytes:
These match 0601 cde4 …, 24f6 e7dc … and b0af 9544 … in the console. Click Confirm on each key. The page then reads 3 of 3 confirmed.

3. Check the signer pairing

The signer gets the pairing code through the shared /state volume, fetches the engine’s key from http://policy-engine:9800, and pins it once:
Pairing over the shared volume and the engine’s HTTP port is an evaluation shortcut. The signer can also pin the engine’s key directly: start it with --pe-public-key <hex> (or PE_PUBLIC_KEY), the hex key the engine logs on first boot. No pairing code is needed then. In production the signer never talks to the engine and pins only that way. See Pairing without a connection.
The fingerprint the signer pinned must be the engine key’s fingerprint from the engine’s log and from Settings → Policy engine. Any other value means the signer pinned a key that is not your engine’s. Stop and see Re-pair the signer with a new engine key.

The platform sees the pairing only after the first wallet

Pairing happens between your engine and your signer, inside your network. The platform has no direct signal for it. It learns of the pairing from the first derived wallet, because the signer derives nothing until it has pinned the engine key. Until step 5:
  • Settings → Signer onboarding → Pair signer with engine reads on your side. Run the signer reads Next and Derive the first wallet reads waiting: the platform sees the signer only through the first wallet too. Onboarding reads 2 of 5, the engine and the org connection.
  • Settings → Policy engine → Signer → Engine key reads pinned on your side.
After the first wallet in step 5, the step reads Done, Onboarding reads 5 of 5, and the engine key row reads pinned. The panel shows citadel-signer pair --engine … --code <pairing-code>. With this compose file you do not run it: the signer reads the code from /state/pairing-code and pairs on its own. Check the pairing on your side, in the signer’s log. One of these lines with a fingerprint means the signer is paired:
engine key pinned appears once, on the first boot. Every later boot prints engine key already pinned. If neither line is there, see Troubleshooting, Signer stuck on waiting for pairing code. A wallet issued in step 5 also proves the pairing: the signer starts serving signer.derive only after it has pinned the engine key.

4. Sign and publish the policy

The engine refuses every payout until a policy document signed by key B is in force. Open Settings → Policy document. Add rules for at least one network. Here, TRON recognises its native coin TRX/6 with a ceiling of 100 TRX per transaction. Click Save draft v1, then v1.yaml to download policy-v1.yaml:
policy-v1.yaml
Sign it with key B inside openbao-engine. The key never leaves the vault. Flags come before the file name; with the file first, the command only prints its usage line.
Paste the signature and click Publish v1. The console checks it against the confirmed key B and sends the document to the engine. The page then reads v1 in force. The engine logs {"level":"INFO","msg":"policy document accepted","org":"org-1f34b7e2fb96"}. Read back what is in force:

5. Derive the first wallet

In the header, select the network your policy covers. Open Vaults and wallets → Issue wallet → New vault and create Payouts (Hot). The dialog returns to Issue wallet. Pick the vault Payouts, the network TRON, fill Identifier (payouts-tron) and End user (treasury), and click Issue. Issue stays disabled until a vault is picked and both fields are filled. The request travels from the console to the engine (policy.keygen) and then to the signer (signer.derive). OpenBao derives the address from the seed, and the engine records the wallet as owned:
The wallet appears in the Payouts vault. Onboarding reads 5 of 5: the derived wallet marks Pair signer with engine as done, and Settings → Policy engine → Signer → Engine key reads pinned. See step 3.

Check that everything works

Restarting is safe

A restart never joins, pairs or creates keys again:
The join token in .env is not used again once /state/state.json and /state/signer/state.json exist.

What lives where

Troubleshooting

The engine’s token carries the signer’s policy, which allows only keys/org. Mount engine-policy.hcl over /openbao/signer-policy.hcl in bao-init-engine, then run dc up -d bao-init-engine. The sidecar rewrites the policy on start, the existing token picks it up, and the engine recovers on its next restart. No join is spent on this failure, because the keys are checked before the join.
One of two causes:
  • the token is older than 24 hours;
  • an engine has already redeemed it.
Mint a new token in Onboarding → Connect the org, put it in .env, and run dc up -d. A component with saved state never reads the token again.
The organization has no tenant account yet. Both containers restart in a loop:
The refusal does not spend the token: Onboarding → Connect the org still shows it as token live. Click Provision tenant there. Both containers join with the same token on their next restart; nothing in .env changes.
The engine has not written /state/pairing-code. Check dc logs policy-engine. The code is valid for 15 minutes from the engine’s boot. If it expired, run dc restart policy-engine to print a fresh code.
The join response carries a NATS URL your containers cannot reach. Set NATS_URL in .env to the NATS address you were given, then dc up -d.
Nothing is in force. Sign and publish as in step 4. Then run check-policy.
pair refuses a key different from the pinned one. Delete /state/signer/engine-key.json by hand, then restart the signer within 15 minutes of an engine boot. If more time has passed, restart the engine as well. Re-check the fingerprint.

Start over

To throw this merchant away, drop every volume of this project. That deletes both vaults: the seed and the engine’s keys.
Then start again from step 1 with a new join token. A down without -v keeps the state. The next up then rejoins nothing. That is the restart path, not a new merchant.
down -v destroys the seed. There is no import and no recovery from words. In demo mode there are no break-glass shares either.