https://api.citadel.example for the API and nats://… for NATS.
Start the stack
.env and run docker compose up.Confirm the engine's keys
Check the signer pairing
Sign and publish the policy
check-policy.Derive the first wallet
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>. xxdandopenssl, orpython3, 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.
1. Start the stack
Create a directory for this merchant with an.env file:
.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 — two OpenBao instances, engine keys in its own vault
compose.yml — two OpenBao instances, engine keys in its own vault
bao-initandbao-init-engineeach initialize and unseal their OpenBao, mountmultichainsign, and write a token:/state/bao.tokenfor the signer and/engine/bao.tokenfor the engine.bao-initalso creates the seed keyorg.policy-enginecreatesengine,requestandpolicyinsideopenbao-engine. It then redeems the join token with the three public halves and writes/state/state.jsonand the pairing code.signerredeems the join token, waits for the pairing code, pins the engine key, checks thatmultichainsign/keys/orgexists, and starts serving on NATS.
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 thefingerprint 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:
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:
--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.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.
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 coinTRX/6 with a ceiling of 100 TRX per transaction. Click
Save draft v1, then v1.yaml to download policy-v1.yaml:
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.
{"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 createPayouts (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:
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:.env is not used again once /state/state.json and
/state/signer/state.json exist.
What lives where
Troubleshooting
policy-engine restarts with ensure key engine: bao 403 permission denied
policy-engine restarts with ensure key engine: bao 403 permission denied
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.Engine log: join token unknown, expired, or already used by this engine
Engine log: join token unknown, expired, or already used by this engine
- the token is older than 24 hours;
- an engine has already redeemed it.
.env, and run dc up -d.
A component with saved state never reads the token again.Engine log: answered 422: not_provisioned; signer: discovery: http 422
Engine log: answered 422: not_provisioned; signer: discovery: http 422
token live. Click Provision tenant there. Both containers join with the same token
on their next restart; nothing in .env changes.Signer stuck on waiting for pairing code
Signer stuck on waiting for pairing code
/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.Signer: nats connect fails
Signer: nats connect fails
NATS_URL in
.env to the NATS address you were given, then dc up -d.Engine refuses every payout: no policy document
Engine refuses every payout: no policy document
check-policy.Re-pair the signer with a new engine key
Re-pair the signer with a new engine key
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.down without -v keeps the state.
The next up then rejoins nothing. That is the restart path, not a new merchant.