Deploying with Docker Compose
This directory holds the release images (image/) and one Docker Compose bundle per host: hub/ for the private
hub host and public/ for the public host. The two hosts are never one machine (CLAUDE.md).
Deploy from a release tag. The bundles and deploy/setup.sh pin the three SatBooth images by tag and digest, and
the pins in the tree of a tag are the ones that release published. On both hosts, in a clean checkout, verify the tag of
the release you deploy with git tag -v <tag>, after importing the release signing key and comparing its fingerprint
(SECURITY.md, “Release signing key”), then run git switch --detach "refs/tags/<tag>^{commit}" and check that
git rev-parse HEAD equals git rev-parse "refs/tags/<tag>^{commit}". Between releases, the pins on main name the
last release while the code around them may be newer. “Release images” below says how to verify the images
themselves, and docs/releasing.md is the maintainer’s procedure.
Set up with satbooth-setup
Section titled “Set up with satbooth-setup”satbooth-setup writes every configuration file of a host and generates every secret, once per host: you answer a few
questions and it writes what the tables under “Per-host bundles” describe, with the modes and the values the bundles
expect. It opens no network connection and runs no other program: it prints the commands you run next. It never
overwrites a file, and it never prints a secret, a descriptor or a Monero address. The same files can still be written
by hand, as the tables below describe. qualify/REPORT.md (“Deployment bundles”) records the bundles started from the
setup command’s output.
deploy/setup.sh runs one stage of the command in the pinned release tools image, which it pulls only when that digest
is not already on the machine. With SATBOOTH_BUILD=1 in the environment it builds that image from this repository on
every run instead (the Go build is offline, since the modules are vendored, but the first build needs Docker to pull the
pinned base image), and the start commands the stages print then name each host’s compose.build.yml.
The container the stage runs in has no network, a read-only root, no capabilities and only the setup directory (and the
files you name) visible. Stages run in a fixed order, two on each host, and each refuses to run out of turn.
What the setup command supports: the two bundles as they are, a new hub with its own bitcoind. The bundle’s dedicated
bitcoind is the only supported node: an existing bitcoind, an existing Alby Hub, a hub on LND or Core Lightning and a
hub hosted by Alby are not supported. If you come from BTCPay Server, docs/migrating-from-btcpay.md says what moves
and what does not.
Before you start
Section titled “Before you start”- Two hosts, the hub host (private) and the public host. They are never one machine.
- The private link between them, with an IPv4 address on each (WireGuard, or a private network and a firewall that admits only the public host to the hub host’s ports; see “Per-host bundles”). Setting it up is yours. The two addresses are answers, and each must be outside the bundles’ own Docker networks.
- Docker with Compose on both hosts, and this repository checked out on both, at the release tag you deploy. The
setup wrapper needs Docker Engine in its default mode:
deploy/setup.shrefuses a rootless engine or Podman (“a rootless Docker engine or Podman is not supported by this wrapper (the bundles are qualified on Docker Engine in its default mode)”), because the bundles are qualified on the default mode only. - From the admin system, the refund approval public key or keys, if you want refunds:
satbooth-approve keygen -out <path>there prints the public key (docs/refunds.md, “Refunds” and “satbooth-approve”). The admin’s signing key lives on neither host, andsatbooth-approveis not in the tools image: the setup command takes public keys only. - For the on-chain rail, a watch-only descriptor of a new account (
docs/bitcoind.md, “Setting up bitcoind (on-chain)”); for Monero, the primary address, and later the private view key and a restore height (docs/monero.md, “Setting up monerod and monero-wallet-rpc (Monero)”).
The answers
Section titled “The answers”Each host is given its answers once, at its first stage: from a file (-answers <file>, one JSON object of at most 16
KiB, keys as in the tables, an unknown or repeated key refused) or, without -answers, by questions on the terminal. A
key left out of a file takes the default below; a key without a default is required. host_dir is the base directory as
the host sees it: the absolute path of <dir> with symbolic links resolved (letters, digits and /._- only, at most
200 bytes). A <dir> that is itself a symbolic link (also when written with a trailing / or /.), or that is .,
.. or ends in /.., is refused by the wrapper: give the real directory. A real directory written with a trailing
/ or /. is the directory itself. The wrapper gives it to the command, so prompts do not ask for it; an answers
file must contain it, with exactly that value, and a file with another path is refused with “satbooth-setup: setup:
the answers’ host_dir is not the directory this command was given (-host-dir)”. The file is yours to delete
afterwards: it may hold the descriptor and the Monero address, and the setup directory’s state.json keeps a copy of
the answers (it is mode 0600, and no service mounts it).
The public host, stage 1:
| Answer | Meaning | Default |
|---|---|---|
network |
mainnet, signet or regtest |
none |
host_dir |
this host’s base directory, as above; required in a file, given by the wrapper with prompts | none |
hub_private_addr |
the hub host’s address on the private link: an IPv4 address, not in 172.30.78.0/24, not link-local or reserved; loopback only on regtest |
none |
public_private_addr |
this host’s address on the private link: the same rules, and different from the hub’s | none |
public_addr |
the address the checkout page is published on (PUBLIC_ADDR): 127.0.0.1 behind a reverse proxy, never 0.0.0.0 |
127.0.0.1 |
public_port |
the port it is published on (PUBLIC_PORT) |
8080 |
checkout_base_url |
the checkout page’s public URL: http or https, no query |
none |
webhook_url |
where SatBooth sends signed payment webhooks | none |
alerts_url |
where operator alerts go; must differ from webhook_url; empty: no alerts |
empty |
descriptor |
the watch-only descriptor of the on-chain rail (wpkh or tr, one extended public key, /0/*; a private key is refused); an xpub on mainnet, a tpub otherwise; empty: no on-chain rail |
empty |
monero_address |
the Monero primary address (checked as a mainnet address); empty: no Monero rail | empty |
monero_network |
mainnet, the only value: the hub bundle runs Monero on mainnet only; required with monero_address (the prompt offers it as the default) |
none |
approval_keys |
the refund approval public keys (1 to 8 distinct keys of 64 lowercase hex characters each); empty: no refunds | empty |
payout_budget_sat |
the payout connection’s budget in sats; it goes into the pairing link | none |
payout_max_sat |
the most one refund may pay, in sats; at most the daily cap and the budget | none |
payout_daily_sat |
the most refunds may pay in a UTC day, in sats | none |
payout_alert_sat |
alert when the budget’s headroom falls below this many sats; 0: no alert; below the budget |
none |
currencies |
the fiat currencies to price in, such as ["EUR","USD"] (at most 32 distinct codes of 3 uppercase letters, not BTC or XMR); empty: no fiat pricing |
empty |
The four refund caps have no default: money leaves through refunds, so every cap is yours to give, and each is asked
whenever approval_keys is not empty (and refused in a file without it). network must name the chain the hub is on:
testnet is refused (see “What the setup command leaves to you”).
The hub host, stage 2: the first three are typed here as well and compared with the public host’s: the handoff carries the public host’s copies so that the stage can refuse when the two hosts were given different values.
| Answer | Meaning | Default |
|---|---|---|
network |
as above; the same value as on the public host | none |
host_dir |
this host’s base directory, as above | none |
hub_private_addr, public_private_addr |
the same two addresses as on the public host, under the same rules (outside hub_subnet and electrum_subnet too) |
none |
hub_admin_addr |
where the hub’s admin page is published (HUB_ADMIN_ADDR): an IPv4 loopback or private address, never one of the two link addresses and not inside hub_subnet or electrum_subnet |
127.0.0.1 |
hub_subnet |
the hub bundle’s Docker network (HUB_SUBNET): an IPv4 network of /16 to /29 |
172.30.78.0/25 |
hub_ip |
the hub’s fixed address in it (HUB_IP): not its network, gateway or broadcast address |
172.30.78.10 |
electrum_subnet |
the bundle’s internal Docker network (ELECTRUM_SUBNET); must not overlap hub_subnet |
172.30.78.128/25 |
A minimal answers file for the public host (Lightning only):
{ "network": "mainnet", "host_dir": "/srv/satbooth", "hub_private_addr": "192.0.2.20", "public_private_addr": "192.0.2.10", "checkout_base_url": "https://pay.example.com", "webhook_url": "https://hooks.example.com/satbooth"}and for the hub host (every other answer is a default):
{ "network": "mainnet", "host_dir": "/srv/satbooth-hub", "hub_private_addr": "192.0.2.20", "public_private_addr": "192.0.2.10"}With questions instead of a file, a prompt shows its default in brackets and an empty line takes it; - clears an
optional answer. Related answers are checked as a group after the last of them. When one is refused, the reason is
printed (it never repeats what you typed) and the group is asked again with your other answers kept; a second refusal
of the same answer asks every question again, with [keep] in place of the old answer (an empty line keeps it, and it
is never displayed).
The stages
Section titled “The stages”In the commands, <dir> is the base directory of the host: a path of your own choosing, such as /srv/satbooth on the
public host and /srv/satbooth-hub on the hub host. deploy/setup.sh creates it with mode 0700 if it is missing. Run
every command from the root of this repository. The command prints its report on standard output and its prompts on
standard error; its exit code is 0 when done, 1 when it refused or failed and 2 for a usage error (an unknown stage or
flag, an extra argument, or -host-dir, -hub-url, -answers, -wallet-rpc-pass or -handoff given empty).
-
public init, on the public host. Writes SatBooth’s secrets and the handoff for the hub host.Terminal window deploy/setup.sh <dir> public init -answers <answers file>(Leave out
-answersto be asked.-hub-urlis the address of the hub’s admin page as you will reach it through the tunnel; it defaults tohttp://127.0.0.1:8080, and only the pairing links carry it.) It writes, under<dir>: inetc/,nwc-client.key,webhook.secret,api-keys.json(the hashes of the API keys, never the keys) and, as the answers call for them,payout.key(refunds),alerts.secret(alerts) andbitcoind.pass(on-chain); insetup/api-keys/, one plain API key per file (orders-write.key,orders-read.keyand, with refunds,refunds-write.keyandrefunds-read.key); andsetup/public-to-hub.json, the handoff. It prints:- the pairing links: the invoice link and, with refunds, the payout link. Do not open them yet. Keep them for step 2.
- the digest of the handoff file (eight groups of eight hex characters).
- the steps to take: copy
<dir>/setup/public-to-hub.jsonto the hub host, and give the hub host the digest to compare. Copy the file by any channel you trust (scp, for one); take the digest by another, if you can (see “The handoff files and their digests”).
-
hub init, on the hub host. Writes the hub host’s configuration and secrets, then you start the hub alone and pair it.Terminal window deploy/setup.sh <dir> hub init -handoff <public-to-hub.json> -digest "<the digest of step 1>" -answers <answers file>(Without
-digestit asks for it. Without-answersit asks the questions.) The digest is checked before anything is written. The stage writes, under<dir>: inconfig/,hub.env,fulcrum.confandbitcoin.conf(with therpcauthlines of the hub’s, Fulcrum’s and, with the on-chain rail, SatBooth’s bitcoind users and theirrpcwhitelistlines) and, with Monero,monerod.conf,wallet-rpc/rpc-loginandwallet-rpc/wallet.pass;compose.env; a copy of the handoff it read,setup/read-public-to-hub.json; and, with Monero,setup/carry/wallet-rpc.pass, the copy of the wallet-rpc login password that step 3 carries to the public host. It prints the digest it computed (it matched the one you gave) and these steps, which you do in this order. Below,dcstands fordocker compose -f deploy/hub/compose.yml --env-file <dir>/compose.env.-
Start the hub alone:
dc up -d --build hub. This startsvolume-init,netns, bitcoind and the hub. -
Open an SSH tunnel to its admin page from your workstation, with the address of
hub_admin_addr(loopback by default):ssh -L 8080:<hub_admin_addr>:8080 <hub host>, then openhttp://127.0.0.1:8080. -
Set the unlock password now, before anything else. A fresh hub belongs to whoever opens its page first.
-
Open the two links from step 1 in the same browser (the second only with refunds). Check the methods and the budget, leave “Read your node info” ticked (the hub states its network only to a connection that holds it, and SatBooth refuses to start with a connection whose
get_infonames none; leave it ticked on both connections: an unticked one makes the start fail with reasoninvoice_connectionorpayout_connectionand the cause “the invoice connection’s get_info does not state the network; pair it with satbooth-pair -connection invoice” (payoutin both places for the payout connection)), click Connect, and in each connection’s “Connection Details” copy the “Wallet Public Key”: the invoice connection’s, then the payout connection’s. You need them in step 3. (docs/running.md, “Pairing with the hub”.) -
With the on-chain rail, make the watch-only wallet and import the descriptor once bitcoind runs. Let
bcstand fordc exec bitcoind bitcoin-cli -conf=/etc/bitcoin/bitcoin.conf -datadir=/data:Terminal window bc -named createwallet wallet_name=satbooth disable_private_keys=true blank=true descriptors=true \load_on_startup=truebc getdescriptorinfo "<your descriptor>"bc -named -rpcwallet=satbooth importdescriptors \requests='[{"desc":"<your descriptor with its #checksum>","active":true,"internal":false,"timestamp":"now"}]'getdescriptorinfoprints the descriptor’s checksum, which the import needs after a#.docs/bitcoind.md, “Setting up bitcoind (on-chain)”, explains each command and what to check after it. -
With Monero, make the view-only wallet once monerod runs (it is in the
moneroprofile; the bundle’s own Compose commanddc --profile monero up -d monerodstarts it alone, and the setup command does not print that line):Terminal window dc --profile monero run --rm --no-deps --entrypoint monero-wallet-cli wallet-rpc \--generate-from-view-key /wallet/satbooth-view --password-file /etc/wallet-rpc/wallet.pass \--restore-height <height> --daemon-address monerod:18081 --log-file /tmp/wallet-cli.logIt asks for the primary address and the private view key (the setup command never asks for it). The new wallet’s password is read from
/etc/wallet-rpc/wallet.passin the container, which is the file<dir>/config/wallet-rpc/wallet.pass(stage 2 wrote it, and wallet-rpc opens the wallet with it), so you neither type nor read it; do not paste it anywhere.docs/monero.md, “Setting up monerod and monero-wallet-rpc (Monero)”, has the restore height and the checks.
-
-
hub pair, on the hub host. Writes the relay’s configuration from the Wallet Public Keys and the handoff back for the public host.Terminal window deploy/setup.sh <dir> hub pair -invoice-wallet <invoice Wallet Public Key> -payout-wallet <payout Wallet Public Key>With refunds off, give the payout flag empty:
-payout-wallet "". A flag left out makes the stage ask for that key (at its prompt, an empty line means no payout key), so a script must give both flags. It writesconfig/relay.json(the pairs: the hub’s keys and the client keys of step 1) andsetup/hub-to-public.json. It prints the digest of that file and these steps:- Start the rest:
dc up -d --build, or with Monerodc --profile monero up -d --build. The relay starts now thatrelay.jsonexists. With Monero, make the view-only wallet first (step 2.6): wallet-rpc refuses to start until the wallet exists. - Give the public host’s operator the digest to compare.
- Copy
<dir>/setup/hub-to-public.jsonto the public host. With Monero, also copy<dir>/setup/carry/wallet-rpc.passthere as a file of mode 0600, then delete it on this host.
- Start the rest:
-
public finish, on the public host. Writessatbooth.jsonand the rest of what SatBooth reads.Terminal window deploy/setup.sh <dir> public finish -handoff <hub-to-public.json> -digest "<the digest of step 3>" \-wallet-rpc-pass <the carried file>(
-wallet-rpc-passonly with Monero; it must be exactly what step 2 wrote: 64 lowercase hex characters and a newline, mode 0600.) It writesetc/satbooth.json, with Moneroetc/wallet-rpc.pass,compose.envandsetup/read-hub-to-public.json. It prints the start command:Terminal window docker compose -f deploy/public/compose.yml --env-file <dir>/compose.env up -d --buildHand over the secrets (step 5) before you run it. (The printed commands use the pinned release images. To build them from source, see “Build from source” under “Release images”.)
Run
public finishbefore the first start of this bundle: at the first startvolume-initgivesetc/to the user 65532, and the setup command then refuses the directory: “setup: etc belongs to another user: give it back to the user running this command (chown), or set up from a fresh directory”. If the bundle was started before stage 4, give the directory back to the calling user withsudo chown "$(id -u):$(id -g)" <dir>/etc, run stage 4, and start again (volume-initgivesetc/to 65532 again). -
After stage 4, before the public bundle’s first start, and afterwards.
- Hand over the secrets now, while you can still read them. The merchant’s application gets the plain API keys in
<dir>/setup/api-keys/on the public host (one file per key: the orders keys for creating and reading orders, the refunds keys for refunds) and the webhook secret<dir>/etc/webhook.secret(the endpointmain’s), to verify webhooks (docs/merchant-api.md, “The merchant API”, anddocs/webhooks.md, “Webhooks”). The receiver of alerts gets<dir>/etc/alerts.secret(docs/alerts.md, “Alerts”). After the first startetc/and its files belong to the user 65532, and reading them needs root (for examplesudo cat); the plain API keys insetup/api-keys/stay readable, because no service touchessetup/. - Delete the carried password everywhere it is a copy: the channel you copied it through,
setup/carry/on the hub host and the file you gave to-wallet-rpc-passon the public host (etc/wallet-rpc.passis the one that stays). Delete the handoff copies you made on your workstation, and the answers files. - Unlock the hub in its admin page after each hub restart; SatBooth cannot start until it answers (see “The hub host” and “Restarts”).
- Hand over the secrets now, while you can still read them. The merchant’s application gets the plain API keys in
Adding a webhook endpoint
Section titled “Adding a webhook endpoint”After setup satbooth.json has one webhook endpoint, named main, whose secret is etc/webhook.secret. Further
endpoints receive every event too, each with a secret of its own (docs/webhooks.md, “Webhooks”, and
docs/configuration.md, “Configuration”). To add
one:
-
Make its secret file in
etc/: 64 to 128 lowercase hex digits (32 to 64 bytes), mode 0600, different from every other secret in the list and from the alert secret (thealertsblock’ssecret_file). After the first startetc/belongs to the user 65532 with mode 0700, so write the file as root, for examplesudo sh -c 'umask 077; openssl rand -hex 32 > <dir>/etc/second.secret'. -
Add an entry to
webhooksin<dir>/etc/satbooth.json, with a name of your own that is not yet in the list, the receiver’s URL and the file’s path as the container sees it:{ "name": "second", "url": "https://hooks.example.com/second", "secret_file": "/etc/satbooth/second.secret" } -
Give the new file to the user that reads it:
volume-initdoes that (the bundle’s one-shot container, which givesetc/’s files to 65532 and never changes a mode; see “Per-host bundles”). Then restart SatBooth, which reads the configuration at start only:Terminal window dc() { docker compose -f deploy/public/compose.yml --env-file <dir>/compose.env "$@"; }dc run --rm --no-deps volume-initdc restart satbooth
A refused configuration (a name, URL or secret file used twice, two equal secrets, a ninth entry) stops the start with
a message that names the entry by its position counted from 0, webhooks[1] being the second entry, and shows no URL
or secret. The new endpoint is sent the events of the last 24 hours at its first delivery pass. Give the receiver the
secret file’s content, by a channel you trust. To remove an endpoint, delete its entry and restart; its recorded
attempts stay.
The directories
Section titled “The directories”Each host has one base directory <dir> with three kinds of content:
<dir>/setup/: the command’s own files:state.json, the handoffs written and read, the plain API keys (api-keys/, public host) andcarry/wallet-rpc.pass(hub host, with Monero). No service mounts it.state.jsonholds the stage reached, the answers and the public values later stages need, never a secret.- On the public host,
<dir>/etc/isSATBOOTH_ETC,<dir>/backups/isSATBOOTH_BACKUPS, and<dir>/compose.envis the bundle’s env file. - On the hub host,
<dir>/config/isHUB_CONFIG_DIR,<dir>/wallet/isMONERO_WALLET_DIR, and<dir>/compose.envis the bundle’s env file.
The base directory and every directory a stage writes into must be a real directory (never a symbolic link) that
belongs to the user running the command and has mode 0700; the command refuses otherwise. The refusals are “setup: the
base directory (-dir) must have mode 0700: run chmod 700 on it” and “setup: the base directory (-dir) belongs to another
user: give it back to the user running this command (chown), or use a fresh directory”; for setup/, etc/ and
config/, “setup: <name> must have mode 0700: run chmod 700 on it, or set up from a fresh directory”, “setup: <name>
belongs to another user: give it back to the user running this command (chown), or set up from a fresh directory” and
“setup: <name> must be a real directory (not a symbolic link): set up from a fresh directory”. A first
stage also refuses a base directory that already holds any of its
directories. Every file is created with mode 0600 and every directory with 0700, whatever the umask. The base
directory’s path may hold only letters, digits and /._- (it goes into a Compose env file), and the wrapper refuses a
colon in any path it mounts (Docker’s mount syntax).
What volume-init changes at the first start. On the public host it gives etc/ and backups/ to the user
65532 and sets them to 0700, and etc/’s files go with it: after the first start you read them as root. On the hub
host it gives each configuration file to the user that reads it (the hub, bitcoind, Fulcrum, and with Monero
monerod.conf and wallet-rpc/ to 10002; relay.json, if it exists, to 65532) and wallet/, Monero or not, to 10002,
and keeps the file modes.
It never touches setup/ or compose.env. That is why a stage that writes into etc/ (stage 4) must come before the
public host’s first start.
The handoff files and their digests
Section titled “The handoff files and their digests”Two files cross between the hosts, and they carry public values only:
public-to-hub.json(stage 1 to 2): the network and the two private addresses (compared, never used), the two NWC client public keys (the second is empty without refunds), SatBooth’srpcauthline (empty without the on-chain rail), and whether Monero is on and the login’s user name.hub-to-public.json(stage 3 to 4): the hub’s two Wallet Public Keys.
The one secret that crosses is the Monero wallet-rpc login’s password, as the carried file, because its HTTP Digest login needs it in plain text on both hosts. It is in neither handoff.
The stage that writes a handoff prints the SHA-256 of its bytes, in eight groups of eight hex characters. The stage
that reads it takes that digest from you (-digest, or a prompt: quote it in a shell, or give it without spaces) and
refuses, before it writes anything, a file whose digest differs. The file holds no digest of itself. The digest
protects against a corrupted copy and against a channel that carries only one of the two; if one channel carries both
the file and the digest, whoever controls it can replace both, so take the digest by another channel when you can.
A stage refuses, with these messages (each is printed after satbooth-setup: , and nothing is written):
| Cause | Message |
|---|---|
| the digest differs, or is not 64 hex characters | setup: the handoff file's digest is not the one given; nothing was written |
| the file is empty (one over 4096 bytes is read cut short, so its digest does not match: the first row) | setup: the handoff file is empty or larger than 4096 bytes |
| not JSON of the right shape, a key missing, repeated or unknown, or another version | setup: the handoff file is not a handoff of this version |
| the other direction’s file | setup: this is the other host's handoff file |
a value that is not allowed (an address that is not an allowed private-link address, an unknown network, equal keys, a malformed rpcauth line) |
setup: the handoff file holds a value that is not allowed |
| stage 2: the network or an address differs from your answers | setup: the two hosts were given different values: network (or hub_private_addr, public_private_addr) |
| stage 3 or 4: refunds are on at one host and off at the other | setup: the two hosts were given different values: payout Wallet Public Key (refunds are on on the public host) at stage 3 (off in the other case) and setup: the two hosts were given different values: payout_wallet (refunds are on on this host) at stage 4 |
| stage 4: the carried file given without Monero, or missing with it | setup: the two hosts were given different values: wallet-rpc password file (Monero is on on this host) (or off) |
| stage 4: the carried file is not what stage 2 wrote | setup: the carried password file must be 64 lowercase hex characters and a final newline |
| stage 4: the carried file is readable by group or other | the carried password file must be mode 0600 (no group or other access) |
If a stage is interrupted
Section titled “If a stage is interrupted”A stage journals what it is about to create (setup/journal.json) before it writes anything, writes every file under a
temporary name, links each into place and replaces setup/state.json last, so a stage run twice, or after a crash,
refuses and changes nothing. After a crash the next run of the stage says:
satbooth-setup: setup: an earlier run of this stage did not finish; run it with -discard-incompleteRun the same command with -discard-incomplete (and the same <dir>; it takes no answers and asks nothing):
deploy/setup.sh <dir> hub init -discard-incompleteThe journal holds, for every file the run may create, the SHA-256 of the exact bytes it writes (a hash of a secret
reveals nothing of it). -discard-incomplete removes a journaled final file only if it exists and its content is that,
and the command’s own scratch files (setup/state.json.tmp and the files in setup/tmp-<stage>/) whatever their
content: it removes the files the unfinished run wrote and the directories it made that are empty (a directory that holds anything
else stays), puts back the exact state.json the run replaced, and removes the journal last. A file that exists under a
journaled name with other content (made by hand after the run died, or changed since) is left in place, and the command
prints one kept <name> line for it:
removed config/relay.jsonkept compose.envEach kept file was not written by the unfinished run (it has other content): it is still there, and the stage will notrun until you move or delete it.The unfinished run is discarded.The stage then refuses to run while a kept file is in its way (“a file this stage writes already exists: …”), as for
any file that exists. -discard-incomplete works only on the journal of its own stage and refuses, removing nothing, a
journal that names a path the stage never writes, is of another stage, has a missing or malformed hash, or is empty or
unreadable (inspect it and delete it by hand). state.json and journal.json are read as strictly as the handoffs: one
JSON object, every key exactly once, no unknown key, nothing after the object. If a crash or a discard leaves an empty
setup/ behind, the stage refuses with “a file this stage writes already exists: setup (an empty setup/ was left
behind by an interrupted run or discard: remove it with rmdir and run the stage again)”, and -discard-incomplete says
“there is nothing to discard: setup/ is empty (an interrupted run or discard left it): remove it with rmdir”. With no
unfinished run at all (a fresh directory, or a finished stage) it says “setup: there is nothing to discard: no earlier
run of a stage was left unfinished”.
Only one satbooth-setup run can use a directory at a time, a stage or -discard-incomplete: a second one is refused
with “setup: another satbooth-setup run is using this directory”, before it asks a question, reads an answers or
handoff file, or writes anything in the directory. The lock is held from there until the command ends. Run it again
when the first has ended.
Two other refusals name their fix. A stage run out of turn says “this stage does not follow the one recorded in setup/state.json”; a directory set up for the other host says “this directory was set up for the other host”; a stage whose files exist lists them (“a file this stage writes already exists: …”): the setup command never replaces them.
What the setup command leaves to you
Section titled “What the setup command leaves to you”- Testnet, for development. SatBooth runs on
testnetand the other test networks for development work; a shop runs on mainnet. The setup command writes no testnet setup: it refusestestnetwith “set up testnet by hand (deploy/README.md)”. Write the files by hand from the tables under “Per-host bundles”. The hub’sHUB_NETWORKisbitcoinorsignet: the hub mapstestnetto signet (itslnclient/ldk/ldk.go:206-213, quoted in the Values table), so a testnet hub is a signet hub, and bitcoind and SatBooth’snetworkmust name the chain the hub is on. Give such a hubHUB_NETWORK=signet, nevertestnet: a hub set totestnetstatestestnetwhile it runs on signet, so SatBooth refuses it at start undersignet, and refuses its invoices undertestnet. - A Monero network other than mainnet. The hub bundle runs Monero on mainnet only (its
monerod.conf, themonerodcommand andwallet-rpc.shcarry no network flag), so the setup command refusesstagenet,testnetandregtestasmonero_network, with a message that says to set up monerod and wallet-rpc by hand (docs/monero.md, “Setting up monerod and monero-wallet-rpc (Monero)”) and to add themoneroblock tosatbooth.json. Leavemonero_addressempty in the answers, set up the Monero side by hand and add the block tosatbooth.jsonafter stage 4. - IPv6. The bundles were qualified on IPv4 only, and the setup command refuses an IPv6 address in three places:
hub_private_addrorpublic_private_addrwith “setup: answers: hub_private_addr is an IPv6 address: set up an IPv6 private link by hand (deploy/README.md)” (the field’s name is the answer’s);public_addrwith “setup: answers: public_addr is an IPv6 address: set up an IPv6 listener by hand (deploy/README.md)”; andhub_admin_addrwith “setup: answers: hub_admin_addr must be an IPv4 address (not a hostname, a zone, or a reserved, wildcard, multicast or link-local address)”. By hand:PUBLIC_ADDRandHUB_ADMIN_ADDRare the host part of a published port, written"${PUBLIC_ADDR}:<port>:8080"and"${HUB_ADMIN_ADDR}:8080:8080"in the compose files, so an IPv6 value needs brackets there, in the SSH tunnel’s-Loption and in the reverse proxy; none of this was qualified. For the private link, the address is in these values: on the hub host,HUB_PRIVATE_ADDR(the relay’s, bitcoind’s and wallet-rpc’s published ports, written the same way) andclient_relay_urlinrelay.json, and therpcallowipline of the public host inbitcoin.conf; on the public host,nwc.relay_url,onchain.rpc_url,monero.wallet_rpc_urlandpayout.relay_urlinsatbooth.json. Check that each accepts the address form, and bracket it where a port follows. - The private link itself: the tunnel or network between the hosts and its firewall.
- A TLS reverse proxy in front of the checkout page, which serves
checkout_base_url.public_addrdefaults to127.0.0.1, for a proxy on the same host. - Changing a default afterwards. The setup command writes the defaults of the example configuration (limits, the
confirmation tiers,
rates.margin_percentof"0", and so on). Change one by editingetc/satbooth.json(docs/configuration.md, “Configuration”; the file is the service’s user’s after the first start, so edit it as root) and restarting SatBooth. Forhub.env,bitcoin.confand the others, edit the file underconfig/the same way. - A second pairing or a rotated key, with
satbooth-pair(docs/running.md, “Pairing with the hub”), which the tools image also holds as/satbooth-pair.
Release images
Section titled “Release images”deploy/image/Dockerfile builds three images, satbooth, satbooth-relay and satbooth-tools. Each is FROM scratch: a static Go
binary and nothing else. They run as the numeric user 65532:65532 (there is no passwd file). Run them with a read-only root filesystem
(--read-only), all capabilities dropped (--cap-drop ALL) and --security-opt no-new-privileges:true.
satbooth also needs --ulimit memlock=-1 (it refuses to start without it, unless allow_swap is set) and a data
volume owned by 65532 with mode 0700. These are run options, not image content. scripts/image-check.sh lists every
entry in every layer of a built image and fails on anything but the files below (regular files and directories only);
CI runs it on each image.
Where the images live and how they are pinned
Section titled “Where the images live and how they are pinned”A release publishes the three images to ghcr.io/satbooth/satbooth, ghcr.io/satbooth/satbooth-relay and
ghcr.io/satbooth/satbooth-tools, for linux/amd64 and linux/arm64, each as one multi-platform image tagged with the
release version. The bundles do not pull by tag. Each pins tag@sha256:<digest> at three sites: the service satbooth
of deploy/public/compose.yml, the service relay of deploy/hub/compose.yml and the tools_image= line of
deploy/setup.sh. A tag in a registry can be moved; a digest cannot name other content. scripts/release-pins.sh show
prints the three pinned refs of the checkout you are in, and scripts/release-pins.sh check-format checks that all
three are well formed and carry one version.
Verify the images
Section titled “Verify the images”You need no trust in the registry: the pinned digest is in the tag’s tree, which you verified with git tag -v. Two
checks go further.
By attestation. The release workflow attests each image (a signed build provenance, stored beside the image in the
registry). For each ref printed by scripts/release-pins.sh show, remove the :<tag> part so that it reads
<name>@sha256:<digest>. This accepts only the release workflow of this repository, run from this tag on a
GitHub-hosted runner:
gh attestation verify oci://ghcr.io/satbooth/<name>@sha256:<digest> -R SatBooth/SatBooth \ --signer-workflow SatBooth/SatBooth/.github/workflows/release.yml \ --source-ref refs/tags/<tag> --deny-self-hosted-runnersThe attestation proves where an image was built, not who authorised the release: the tag signature is that check.
By rebuild, which needs Docker with buildx and jq. The build is reproducible: the same sources give the same digests
on any machine. It writes large files, so give it a directory outside the checkout. Build and compare with the pins
(the second command prints match or pinned X, built Y for each image):
out=$(mktemp -d)scripts/release-build.sh "$out"scripts/release-pins.sh check "$out/digests.txt"A rebuild checks the hosted runners that made the release. It runs the same pinned BuildKit and Go images, so it does not check them: they are trusted by the release build and by every rebuild alike. The release notes of each release print these commands with the refs filled in, but the signed tag’s tree is authoritative, not the notes.
Build from source
Section titled “Build from source”To run images you built yourself, not the release images, build them from this checkout. For the setup command, set
SATBOOTH_BUILD=1 in the environment of deploy/setup.sh: it builds the tools image on every run instead of pulling
it, and the start commands the stages print include compose.build.yml. Each run prints the start commands for its own
mode and the setup directory does not remember it, so use the same SATBOOTH_BUILD setting for every stage of a host.
For each bundle, add that host’s compose.build.yml after compose.yml:
docker compose -f deploy/public/compose.yml -f deploy/public/compose.build.yml --env-file <your env file> up -d --builddocker compose -f deploy/hub/compose.yml -f deploy/hub/compose.build.yml --env-file <your env file> up -d --buildThe built images are named satbooth:local and satbooth-relay:local, and the build needs Docker to pull the pinned
Go builder. The override replaces the release image of every service that runs one: the public bundle’s one-off service
too. The qualification suite runs the bundles this way.
Files in each image
Section titled “Files in each image”| Image | File | Reason |
|---|---|---|
satbooth |
/satbooth |
The binary, built with CGO_ENABLED=0 go build -trimpath -ldflags=-buildid= from the vendored modules. |
satbooth |
/etc/ssl/certs/ca-certificates.crt |
The CA bundle, copied from the pinned Go image (the certs stage, which always runs for linux/amd64, so the bytes do not depend on the build host). SatBooth makes outbound TLS connections: rate providers, webhooks, wss:// relays and an https:// bitcoind or wallet-rpc. |
satbooth-relay |
/satbooth-relay |
The binary, built the same way. |
satbooth-tools |
/satbooth-setup |
The setup command, built the same way. It has no ENTRYPOINT: deploy/setup.sh names the command. |
satbooth-tools |
/satbooth-pair |
The pairing tool, built the same way. No CA bundle: neither tool uses the network. |
Directories that only hold these files (/etc, /etc/ssl, /etc/ssl/certs) are allowed. There is no tzdata (the
code never calls time.LoadLocation), no shell, no package manager and no other file.
| Pin | Version | Why it is current |
|---|---|---|
golang:1.27.4-trixie@sha256:b54b2639... (the build and certs stages) |
Go 1.27.4 on Debian 13 | The newest Go 1.27 patch release at the time of writing; the regtest setup runs these two images and uses the same pin for its volume-init helper (the bitcoind and Monero helpers use debian:13-slim). Nothing from these stages ships except the binaries and the CA bundle, which the certs stage takes from the linux/amd64 image without running anything. |
moby/buildkit:v0.34.1@sha256:99368f3e... |
BuildKit v0.34.1 | The builder of scripts/release-lib.sh, Docker’s own BuildKit. It is pinned because layer compression and the exporter decide the release image digests. It is trusted to build, not for what operators run; a rebuild runs the same pinned image, so it does not check it. |
actions/checkout@3d3c42e5... |
v7.0.1 | The pin check.yml and release.yml use. |
actions/upload-artifact@cf430e03..., actions/download-artifact@9000827c... |
v7.0.2, v8.0.2 | release.yml moves each runner’s digests to the job that compares them (upload-artifact is also used by qualify.yml). |
actions/attest-build-provenance@4d101475... |
v4.2.2 | release.yml attests each pushed image; the action wraps actions/attest@508db95d... (v4.2.1). |
ghcr.io/getalby/hub:v1.24.6@sha256:4bb27770... (hub bundle) |
Alby Hub v1.24.6 | The version qualified against (qualify/REPORT.md); the same pin as the regtest hubs. |
debian:13-slim@sha256:a72b4433... (hub bundle) |
Debian 13 | The base of the bitcoind and Monero images (decision 5), the netns holder and the hub bundle’s volume-init; the same pin as the regtest helpers. |
| Bitcoin Core 32.1, Monero v0.18.5.4 (hub bundle) | release tarballs, SHA-256 pinned | The versions qualified against; the hashes are the regtest images’. |
| Fulcrum 2.1.4 (hub bundle) | release tarballs, SHA-256 pinned | The newest release (2026-10-18); the hashes are from its PGP-signed shasums.txt, see docs/dependencies.md §8. |
The public bundle’s volume-init uses the Go pin above: its chown and chmod need a shell, which the release images
do not have, and this pinned image is the one the release build already uses, so the public host pulls one base image
besides satbooth.
The CA bundle is refreshed only when the Go digest is bumped. The digest is the full sha256: value in the Dockerfile;
update the tag and the digest together.
Build and check
Section titled “Build and check”To build one image and run the checks that CI runs on each:
docker build -f deploy/image/Dockerfile --target satbooth -t satbooth:local .docker build -f deploy/image/Dockerfile --target satbooth-relay -t satbooth-relay:local .docker build -f deploy/image/Dockerfile --target satbooth-tools -t satbooth-tools:local .scripts/image-check.sh satbooth:local /satbooth /etc/ssl/certs/ca-certificates.crtscripts/image-check.sh satbooth-relay:local /satbooth-relayscripts/image-check.sh satbooth-tools:local /satbooth-setup /satbooth-pairscripts/image-run-test.sh satbooth:local satbooth-relay:local satbooth-tools:localCI runs these checks on every pull request and every push to main (image.yml), with no registry login. Only the
release workflow (.github/workflows/release.yml, docs/releasing.md) pushes to a registry, and only for a release
tag.
Per-host bundles
Section titled “Per-host bundles”| Host | Bundle | Runs |
|---|---|---|
| hub (private) | deploy/hub/compose.yml |
Alby Hub, satbooth-relay, bitcoind, Fulcrum (the hub’s Electrum server), monerod, monero-wallet-rpc |
| public | deploy/public/compose.yml |
satbooth |
Each bundle takes its operator values from an env file, passed with --env-file. The env files hold addresses and
paths only, never a secret. satbooth-setup writes the env file as <dir>/compose.env (stage 2 on the hub host,
stage 4 on the public host); written by hand, it is a copy of example.env with every value changed, kept outside the
repository. The sections below are the reference for every file and value, and say which stage writes each.
The private link is the operator’s job. SatBooth on the public host reaches the hub host’s relay (7448), bitcoind’s
RPC (8332) and wallet-rpc (18083) over a private link: WireGuard, or a private network with a firewall that admits only
the public host to those ports. Setting that up is not this repository’s job. The link carries only these three: they
bind the hub host’s private address (HUB_PRIVATE_ADDR), so nothing listens on its public interfaces even before the
firewall.
The hub’s admin UI never goes over the link. It controls the node’s funds (channels, payments, new NWC
connections), and a fresh hub belongs to whoever opens its setup page first, so the public host, the most exposed
machine, must not reach it. It is published on HUB_ADMIN_ADDR, loopback in example.env, and the operator reaches it
through an SSH tunnel to the hub host:
ssh -L 8080:127.0.0.1:8080 <hub host> # then open http://127.0.0.1:8080Published addresses are required values. Every published port is written "${ADDR:?…}:<port>:<port>": an empty
or missing address is an error, because Compose would read an empty host as every interface. scripts/check.sh runs
docker compose config -q on both bundles with example.env, and checks that an empty address is refused.
Published ports
Section titled “Published ports”| Host | Address | Port | For |
|---|---|---|---|
| hub | HUB_ADMIN_ADDR (loopback) |
8080 | the hub’s web UI and API: the operator’s admin access (setup, unlock, channels, pairing, backups), through an SSH tunnel |
| hub | HUB_PRIVATE_ADDR |
7448 | the relay’s client listener: SatBooth’s NWC connections (NIP-42 AUTH, plain ws://) |
| hub | HUB_PRIVATE_ADDR |
8332 | bitcoind’s RPC: SatBooth’s on-chain rail, as its whitelisted user |
| hub | HUB_PRIVATE_ADDR |
18083 | wallet-rpc (monero profile): SatBooth’s Monero rail, with the Digest login |
| public | PUBLIC_ADDR |
PUBLIC_PORT |
the checkout page (SatBooth’s public listener) |
Nothing else is published. The relay’s hub listener stays on loopback, monerod’s RPC and every peer-to-peer port stay inside the hub bundle (bitcoind, monerod and the hub’s Lightning node connect out; none takes inbound peers), and the merchant API is not published (see “The merchant API”).
The bind-mount rule
Section titled “The bind-mount rule”Configuration and secrets are host files, bind-mounted read-only: no secret goes in an environment: value, an
env_file:, the env file or argv. Compose ignores a secrets: entry’s uid, gid and mode for a file: source
outside Swarm (docs/running.md, “Secrets in Docker Compose”), so the bundles do not use secrets:.
- Keep each secret file mode 0600. Each bundle’s
volume-init(a one-shot container, root with onlyCHOWN,FOWNERandDAC_READ_SEARCH, no network) gives the files to the user that reads them, and the directories mode 0700, before anything else starts.DAC_READ_SEARCHlets it read through a directory it no longer owns: on the nextupits own 0700 directories belong to another user (it grants no write). It never changes a file’s mode: SatBooth’s loaders, and the wallet-rpc entrypoint, still refuse a file with group or other bits, so a mistake shows instead of being repaired. - On Linux a bind mount keeps the host’s numeric owner and mode. Docker Desktop presents a shared file as owned by
the user that reads it, with its host mode (observed on Docker Desktop with Engine 29.9.2: a 0600 file read as
65532 was accepted by SatBooth, a 0644 one refused,
qualify/REPORT.md“Deployment bundles”). The same files work on both.
The hub host
Section titled “The hub host”The first start sets the hub’s password before anything else runs. A fresh hub belongs to whoever opens its setup
page first. So the first up starts only the hub and what it needs (volume-init, netns and bitcoind), and the
first act after it is to set the unlock password over the SSH tunnel; only then does the rest start:
docker compose -f deploy/hub/compose.yml --env-file <your env file> up -d --build hubssh -L 8080:127.0.0.1:8080 <hub host> # open http://127.0.0.1:8080 and set the unlock password nowdocker compose -f deploy/hub/compose.yml --env-file <your env file> up -d --build# or, with Monero on this host:docker compose -f deploy/hub/compose.yml --env-file <your env file> --profile monero up -d --buildLater starts are the last command alone. (satbooth-setup hub init prints these commands with its own env file, and
hub pair prints the second start.) Until relay.json holds a real pair (step 5 below), the relay exits with
code 2 on every start (a configuration without a pair is refused) and unless-stopped restarts it in a loop; that is
expected before pairing, and it stops once the pairs are in and the relay is restarted. With the setup command the relay
is not started until stage 3 has written relay.json, so the loop does not arise.
What can reach the hub’s API. The hub’s HTTP listener binds every address of its namespace (getAlby/hub v1.24.6
cmd/http/main.go:59, ":" and the port, with no setting for the address). So besides the tunnel, bitcoind (on the
hub network) and Fulcrum (on the electrum network) can connect to its API on 8080. That API still needs the
unlock password or a token, and the hub limits unlock attempts; nothing else of the hub is reachable from them, and the
relay’s client listener binds HUB_IP only. A compromised bitcoind or Fulcrum can therefore try the API, and on a hub
that has not been set up yet, take it: hence the order above.
monerod and wallet-rpc are in the monero profile: Monero is optional, and they may run on a third private host
instead (docs/monero.md, “Setting up monerod and monero-wallet-rpc”). Without the profile, monerod.conf,
wallet-rpc/ and the wallet are not needed (MONERO_WALLET_DIR must still name a directory, which may be empty). With
it, a missing configuration file stops the start with “bind source path does not exist”: the bundle never lets Docker
create a missing file’s path as a directory.
-
Values (
deploy/hub/example.env;satbooth-setup hub initwrites them tocompose.envfrom its answers:HUB_PRIVATE_ADDRfromhub_private_addr,HUB_NETWORKfromnetwork(mainnetisbitcoin), the three Docker values from their answers, and the two directories from the base directory, as<dir>/configand<dir>/wallet):Value Meaning HUB_PRIVATE_ADDRthe hub host’s address on the private link; the relay, bitcoind’s RPC and ( moneroprofile) wallet-rpc bind itHUB_ADMIN_ADDRwhere the hub’s admin UI is published: 127.0.0.1, reached through an SSH tunnel; neverHUB_PRIVATE_ADDRor a public addressHUB_SUBNET,HUB_IPthe bundle’s Docker network and the fixed address of the hub and the relay on it; client_listeninrelay.jsonisHUB_IP:7448ELECTRUM_SUBNETthe bundle’s internal network of the hub, bitcoind and Fulcrum (no route out) HUB_NETWORKthe hub’s chain: bitcoinorsignet(the hub mapstestnetto signet, getAlby/hub v1.24.6lnclient/ldk/ldk.go:206-213)HUB_CONFIG_DIRhost directory with relay.json,bitcoin.conf,hub.env, and with themoneroprofilemonerod.confandwallet-rpc/MONERO_WALLET_DIRhost directory with the view-only wallet satbooth-viewandsatbooth-view.keys(any directory without themoneroprofile) -
Files in
HUB_CONFIG_DIR.deploy/hub/config-example/has examples of the three without secrets. Stage 2 ofsatbooth-setupwritesbitcoin.conf,hub.envandfulcrum.conf(with therpcauthlines andrpcwhitelistlines described below, byte for byte those of the example) and, when the public host’s handoff says Monero is on,monerod.confandwallet-rpc/(rpc-login,wallet.pass); stage 3 writesrelay.json.File Read by Holds relay.jsonthe relay (65532) the relay’s configuration ( docs/running.md, “Running the relay”):hub_listen127.0.0.1:7447,client_listenHUB_IP:7448,client_relay_urlws://HUB_PRIVATE_ADDR:7448, which must equal SatBooth’snwc.relay_urlbyte for byte, and the pairs. No secret.bitcoin.confbitcoind (10001) txindex=1(Fulcrum needs it, and an unpruned node),rpcbind=0.0.0.0,rpcport=8332on every chain,rpcallowipforHUB_SUBNET,ELECTRUM_SUBNETand the public host’s private address only,rpcauthlines for the usershubandfulcrum, each confined byrpcwhitelist: the hub’s user to the 11 calls its chain source makes (the chain tip, headers and blocks, the mempool and its transactions, fee estimates, the broadcast;docs/dependencies.md§8), Fulcrum’s to the 15 it makes (config-example/bitcoin.conf), SatBooth’s user to its 12 (docs/bitcoind.md, “Setting up bitcoind”), andrpcwhitelistdefault=0, sobitcoin-cliwith the cookie inside the container stays unrestricted. WithLDK_GOSSIP_SOURCEleft empty (below) the hub checks the channels it learns over gossip against the chain, which needsgetblockhashandgettxout: without them every such check fails and the hub drops the announcement (lightning-block-sync 0.2.0src/gossip.rs), so it learns no channels beyond its own to route through.monerod.conf(moneroprofile)monerod (10002) monerod’s options; its RPC on 0.0.0.0:18081withconfirm-external-bind, for wallet-rpc on themoneronetwork only (--restricted-rpcis added bycompose.yml).hub.envthe hub (10003) secret, 0600: LDK_BITCOIND_RPC_PASSWORD=<the hub user's password>.fulcrum.confFulcrum (10004) secret, 0600: rpcuser = fulcrum,rpcpassword = <its password>,peering = false,announce = false.wallet-rpc/rpc-login(moneroprofile)wallet-rpc (10002) secret, 0600: one line <user>:<password>, SatBooth’smonero.rpc_userand the password in itsmonero.rpc_password_file.wallet-rpc/wallet.pass(moneroprofile)wallet-rpc (10002) secret, 0600: the view-only wallet’s password. -
bitcoind is dedicated to the hub and SatBooth, with no wallet that holds keys loaded on it (
docs/bitcoind.md, “Setting up bitcoind”): SatBooth’s user is confined byrpcwhitelist, but the whitelist is per method, not per wallet. Make the watch-only wallet and import the descriptor withdocker compose … exec bitcoind bitcoin-cli -conf=/etc/bitcoin/bitcoin.conf -datadir=/data …(inside the containerbitcoin-cliuses bitcoind’s cookie file, so no password is typed). Stage 2 prints the three commands (createwallet,getdescriptorinfo,importdescriptors); they are the operator’s, since the descriptor is never in the setup command’s output. -
The view-only wallet is made by the operator (
docs/monero.md, “Setting up monerod and monero-wallet-rpc”), with the bundle’s ownmonero-wallet-cli, once monerod runs:Terminal window docker compose -f deploy/hub/compose.yml --env-file <your env file> --profile monero run --rm --no-deps \--entrypoint monero-wallet-cli wallet-rpc --generate-from-view-key /wallet/satbooth-view \--password-file /etc/wallet-rpc/wallet.pass --restore-height <height> --daemon-address monerod:18081 \--log-file /tmp/wallet-cli.logIt asks for the primary address and the private view key, and takes the new wallet’s password from
wallet-rpc/wallet.pass(/etc/wallet-rpc/wallet.passin the container). wallet-rpc’s entrypoint (deploy/hub/monero/wallet-rpc.sh) refuses to start until the wallet and both secret files exist with mode 0600. It reads the login with the shell’s ownreadand gives it to wallet-rpc asRPC_LOGINin its environment (monero v0.18.5.4src/rpc/rpc_args.cpp:226-228), never in argv. It passes--trusted-daemon, since monerod is the bundle’s own node. -
Set up the hub through the SSH tunnel (
ssh -L 8080:127.0.0.1:8080 <hub host>, thenhttp://127.0.0.1:8080), as the first act after the firstup(above) and before the public host is connected to the private link: the first visitor of a fresh hub sets its unlock password. Unlock it the same way after each hub start. Then pair SatBooth (docs/running.md, “Pairing with the hub”, with-hub http://127.0.0.1:8080over the tunnel) and add the pairs torelay.json; restart the relay to load them (docker compose … restart relay). With the setup command, stage 1 prints the links, you copy the Wallet Public Keys from the hub’s page, and stage 3 writes the pairs.
The hub’s environment. The hub service starts from the regtest one (dev/regtest/compose.yml, hub-satbooth).
Each key is fixed in compose.yml, an operator value, a secret in hub.env, or left to the hub’s default:
| Key | Regtest | Bundle |
|---|---|---|
WORK_DIR |
/data |
fixed: /data (the hub-data volume) |
PORT |
8080 |
fixed: 8080 |
NETWORK |
regtest |
operator: HUB_NETWORK |
LOG_LEVEL |
4 |
fixed: 4 |
LDK_BITCOIND_RPC_HOST |
172.30.78.2 |
fixed: bitcoind (the bundle’s service) |
LDK_BITCOIND_RPC_PORT |
18443 |
fixed: 8332 (bitcoin.conf sets rpcport=8332 on every chain) |
LDK_BITCOIND_RPC_USER |
hub, whitelisted as in the bundle |
fixed: hub |
LDK_BITCOIND_RPC_PASSWORD |
a test string | secret: hub.env |
LDK_GOSSIP_SOURCE |
empty | left out: unset and empty are the same, gossip over the peer-to-peer network (lnclient/ldk/ldk.go:253), whose channel announcements the hub checks with getblockhash and gettxout (both in the hub user’s rpcwhitelist) |
SEND_EVENTS_TO_ALBY |
false |
fixed: false |
AUTO_LINK_ALBY_ACCOUNT |
false |
fixed: false |
MEMPOOL_API |
unreachable, on purpose | left out: the hub’s default (https://mempool.space/api); set it in hub.env to use your own |
LDK_ELECTRUM_SERVER |
not set | fixed: tcp://fulcrum:50001, the bundle’s own Electrum server (below); unset, the hub would use Alby’s (ssl://electrum.getalbypro.com:50002) |
LDK_ESPLORA_SERVER |
not set | left out: set, it would take the address lookups away from Fulcrum (chain/address_lookup.go:20-26) |
RELAY |
ws://127.0.0.1:7447 |
fixed: ws://127.0.0.1:7447 (the relay’s hub listener) |
AUTO_UNLOCK_PASSWORD |
a test string | left out: the operator unlocks the hub in its UI after each hub start |
The hub loads hub.env because it reads .env from its working directory (service/service.go:56 in getAlby/hub
v1.24.6, godotenv.Load(".env"), which never overrides a variable that is already set), and the bundle mounts the file
there. Without AUTO_UNLOCK_PASSWORD, a restarted hub is locked until the operator unlocks it, and SatBooth cannot
start (or reach the wallet) until then; adding it to hub.env makes the hub unlock itself, at the cost of keeping its
unlock password on the host.
Why a netns container. The relay’s hub listener must be on loopback (it needs no login), so the hub and the relay
share one network namespace. A container that joins another’s namespace loses its network when that container
restarts, and gets it back when it restarts itself (observed on Engine 29.9.2: after the owner restarted, the joiner
had no address). So neither the hub nor the relay owns it: netns (the pinned debian:13-slim, running sleep as
65534, read-only, no capabilities) holds the namespace and the two published ports of the hub and the relay, and both
join it, as a Kubernetes pod’s pause container does.
Three networks. hub holds netns (the hub and the relay) and bitcoind; electrum, internal (no route out),
holds netns, bitcoind and Fulcrum; monero holds only monerod and wallet-rpc. Observed on the bundle (regtest): from
the hub’s namespace, bitcoind and Fulcrum answer and monerod’s name does not resolve, nor does its address answer;
Fulcrum reaches bitcoind and nothing outside.
monerod’s RPC has no login, and whoever can call it unrestricted can point it at a chain of their own making
(set_bootstrap_daemon), which wallet-rpc would then report as payments. So no container but wallet-rpc reaches it,
and the bundle starts monerod with --restricted-rpc as well: that keeps every call the wallet makes (monero
v0.18.5.4 src/rpc/core_rpc_server.h:113-189, src/wallet/wallet2.cpp:3177), and hides only pool transactions still
in their Dandelion++ stem until they are relayed (core_rpc_server.cpp:651-654).
The Electrum server. With bitcoind as its chain source, the hub’s Lightning node (LDK) follows the chain, channel
closes and breaches included, from bitcoind alone (lnclient/ldk/ldk.go:217-232, SetChainSourceBitcoindRpc at
:226, in getAlby/hub v1.24.6). Only the hub’s own on-chain address lookups, whether an address has been used (its
address API and swaps, api/address_lookup.go:9-16, swaps/swaps_service.go:1598-1612), need an address index, which
bitcoind lacks; they go to LDK_ELECTRUM_SERVER (chain/address_lookup.go:20-26, Electrum protocol over tcp:// or
ssl://, chain/electrum.go). The bundle runs Fulcrum for them, so they stay on the host. Fulcrum needs bitcoind with
txindex=1 and unpruned, its own index on disk (its README gives 133 GB for Bitcoin as of August 2023, more since) and
at least 1 GB of RAM; it builds the index on first start, which takes hours to days. Until then the hub’s lookups
fail, and nothing of SatBooth’s depends on them.
The node images (deploy/hub/bitcoind/Dockerfile, deploy/hub/monero/Dockerfile, deploy/hub/fulcrum/Dockerfile)
download the official release tarballs of Bitcoin Core 32.1 and Monero v0.18.5.4 (the qualified versions) and Fulcrum
2.1.4, check them against pinned SHA-256 hashes, and copy the binaries onto debian:13-slim pinned by digest (plan 10,
decision 5). The download tools stay in a build stage. bitcoind runs as 10001, monerod and wallet-rpc as 10002, the hub
as 10003, Fulcrum as 10004 and the relay as 65532, each with a read-only root, no capabilities and no-new-privileges. No configuration, password or
regtest setting is in an image.
The public host
Section titled “The public host”satbooth-setup public finish prints the start command below with the env file it wrote, and writes satbooth.json
(stage 4) with the values described here, from the answers and the hub’s Wallet Public Keys.
docker compose -f deploy/public/compose.yml --env-file <your env file> up -d --build| Value | Meaning |
|---|---|
PUBLIC_ADDR, PUBLIC_PORT |
where the checkout page is published: 127.0.0.1 behind a TLS reverse proxy on the host that serves checkout_base_url, or the host’s public address |
SATBOOTH_SUBNET, SATBOOTH_IP |
the bundle’s Docker network and SatBooth’s fixed address on it |
SATBOOTH_ETC |
host directory with satbooth.json and its secret files, mounted read-only at /etc/satbooth; satbooth-setup writes the secret files and api-keys.json at stage 1 and satbooth.json at stage 4 |
SATBOOTH_BACKUPS |
host directory for backups, mounted at /backups in satbooth-oneoff only |
satbooth.json (docs/configuration.md, “Configuration”) names its files under /etc/satbooth/, sets data_dir to
/data (the satbooth-data volume), public_listen to SATBOOTH_IP:8080 and private_listen to SATBOOTH_IP:8081:
SatBooth refuses a wildcard listener, so it listens on its fixed address in the bundle’s network, and the bundle
publishes port 8080 only. nwc.relay_url, onchain.rpc_url and monero.wallet_rpc_url point at HUB_PRIVATE_ADDR
(ports 7448, 8332 and 18083). The service runs as it ships: user 65532, read-only root, no capabilities,
no-new-privileges, memlock: -1, and a 60 s stop grace (the worst-case shutdown with alerts on is 40 s).
The merchant API
Section titled “The merchant API”The private listener is not published. The merchant’s application reaches it at http://SATBOOTH_IP:8081 from the
public host itself (Linux routes the host to its bridge networks), or from a container attached to the bundle’s
network (satbooth-public_satbooth). A merchant application on another host needs a way in that this bundle does not
open: publishing port 8081 on the private link’s address is the operator’s decision, never on a public address.
Backups
Section titled “Backups”satbooth backup runs in a one-off container of the image, without a network, with the service stopped
(docs/backup-and-restore.md, “Backup and restore”):
docker compose -f deploy/public/compose.yml --env-file <your env file> stop satboothdocker compose -f deploy/public/compose.yml --env-file <your env file> --profile oneoff run --rm satbooth-oneoff \ backup -config /etc/satbooth/satbooth.json -to /backups/<name>docker compose -f deploy/public/compose.yml --env-file <your env file> start satboothThe backup is SATBOOTH_BACKUPS/<name>: a directory of the image’s user 65532, mode 0700, whose files are 0600, so
copying it off the host needs root. A directory without manifest.json is not a backup.
Restore
Section titled “Restore”The data directory is the named volume satbooth-data, and the image has no shell, rm or cp. The restore runs in
the bundle’s volume-init service instead (the pinned builder image, which has sh, cp and sha256sum), as the
image’s user 65532 and without a network. scripts/qualify.sh runs exactly these commands in its public-bundle group
(backup, restore, start, the same orders).
dc() { docker compose -f deploy/public/compose.yml --env-file <your env file> "$@"; }
# 1. Stop SatBooth (unless-stopped does not restart a manual stop).dc stop satbooth
# Only on a new host or a new volume: create the volume and give it, and /backups, to 65532.dc run --rm --no-deps volume-init
# 2 and 3. As 65532, without a network: check both files of the ONE backup <name> against its manifest, remove the# live databases with their -wal and -shm files, copy both files, and check the copies.dc run --rm --no-deps -T --user 65532:65532 --entrypoint sh volume-init -c ' set -eu b=/backups/$1 check() { # <file>: the manifest entry named after the file holds its SHA-256 h=$(sha256sum "$1" | cut -d" " -f1) grep -q "{\"name\":\"$(basename "$1")\",\"bytes\":[0-9]*,\"sha256\":\"$h\"}" "$b/manifest.json" } check "$b/store.db" check "$b/ledger.db" rm -f /data/store.db* /data/ledger.db* cp -p "$b/store.db" "$b/ledger.db" /data/ check /data/store.db check /data/ledger.db ls -ln /data' sh <name>With refunds on (a payout block in satbooth.json), do not run the next block yet: replace the approval keys
first (below). Without a payout block, run it now.
# 4. Start. (start runs the existing container. On a new host, where the bundle never ran, the container does not# exist yet: dc up -d satbooth creates and starts it instead. qualify runs the existing-host path.)dc start satboothsatbooth.lockstays; onlystore.db*andledger.db*go. The-waland-shmfiles must go with them: after a crash a left-over-walbelongs to the old database.- A backup directory copied in from elsewhere must be the image user’s first: as root on the host,
chown -R 65532:65532it insideSATBOOTH_BACKUPS. - The command stops at the first file that does not match the manifest, before anything is removed if the backup itself does not match.
- The start then logs no
startup failedwith reasonpair. If it does, the two files are not from one backup: restore both from one backup again. Backups made after<name>belong to a timeline the restore gave up; the pair stamp refuses a file from one of them next to a file of the restored pair.
With refunds on: one rule, before the first start after the restore. The restore puts the store back to the
backup, and “already claimed” is recorded only there: an approval signed before the restore, for a refund claimed
after the backup, could be claimed again with a new invoice (docs/backup-and-restore.md, “Backup and restore” has the
reasons and the reconciliation; docs/threat-model.md §4, “Restoring a backup while refunds are in use”). So, after the
restore command and before the start block:
- On the admin system make a new approval key with
satbooth-approve keygen -out <a new path>(it refuses an existing path and prints the public key). The private key stays with the admin and signs no refund approval yet; destroy the old signing keys. - As root on the host, edit
<SATBOOTH_ETC>/satbooth.json(it is mounted read-only into the container, andsatbooth-setupwrote it mode 0600 for user 65532: keep its owner and mode). Replacepayout.approval_keyswith the new public keys only, and keep thepayoutblock. (approval_keysin the setup answers is for a new set-up; for a restore, edit the file.) A start without the block is not an option: it is refused while the restored store holds a refund with no result. - Run the start block above. An approval signed with an old key is refused at claim (
invalid_approval). A refund the restored store holdsclaimedendsfailedbefore anything is sent (key_revoked, orexpired_unsentwhen its approval has expired). A refund it holdssentis looked up at the hub:settledorfailedends it.pendingleaves it open (anALERTonce an hour has passed since the send) and a void is refused until the hub settles or fails the payment. No record, orexpired, logs anALERTand leaves it open until a void approval, signed with a new key, ends it (accepted from 11 minutes after the send). An answer that does not fit the refund (or a malformed one) logs anALERT, leaves it open and makes a void answerhub_unavailable: a person investigates (docs/refunds.md, “Voiding a refund”). The hub unreachable: the refund stays open and is looked up again; a void answershub_unavailable. - Approve no refund with the new key until the payouts made after the backup are reconciled (a void approval
moves no money and may be signed at any time). Before reconciling, wait at least 11 minutes after the old SatBooth
stopped or its host went down, whichever is later (when unsure, count from the time you are sure). The hub does not
read a request’s expiration: only SatBooth’s relay, on the hub host and by that host’s clock, stops serving a pay
request past its send time plus
payout.timeout_seconds(10 to 600). The 11 minutes are the longest timeout, 10 minutes, plus a minute for the difference between the two hosts’ clocks; both hosts must keep time with NTP, and neither clock may have been stepped by hand. Otherwise a request sent before the restore can still reach the hub, and it and the replacement refund would both pay. A payout the hub still showspendingcounts as paid until the hub shows itfailed: reconcile again when it has resolved, and approve nothing for that order before. The wait does not cover a hub that was stopped or cut off while it held such a request: it can still pay it when it resumes, and the list shows nothing before. Before approving anything for an order with a refund in the hub’s list that the restored store does not know, reconcile again. The backup’s time iscreated_at(Unix seconds) in itsmanifest.json, inSATBOOTH_BACKUPS/<name>. In the hub, list the payments of the payout connection made at or after that time, and match each to the claim that carried its invoice, from the admin system’s or the merchant app’s own record (the refund view never carries the invoice). For a refund the restored store does not know, or showsfailedor open although the hub paid it, count it as paid in every later approval for that order. The store and the ledger never learn such a payout and nobody corrects them by hand: the order’srefunded_msatstays too low for good. - Only then may the admin system sign refund approvals with the new key. Never put the old keys back: they would make every approval signed before the restore valid again.
Orders, invoices and payments recorded after the backup are gone from the restored store: a payment to one of them is
not recorded and not alerted, and the merchant handles it by hand (docs/backup-and-restore.md, “Backup and restore”
says where to look). A payment after the backup to an invoice, address or subaddress that the backup holds is found
again, and one to something issued after the backup is not, even when its order is in the backup: look for it as for a
lost order. On-chain and Monero a payment that is found again is first seen at the restore, so inside the order’s 7-day
watch window after its expiry it is recorded late (order.late and an ALERT), and past the window only an ALERT
names its order, although order.paid may already have been sent: compare with the webhooks received since
created_at, and credit or refund by hand. The addresses and subaddresses issued after the backup are not issued again
while bitcoind’s wallet and the Monero wallet keep their state. If either is rebuilt as well, its next index must be set
above everything issued since the backup (docs/bitcoind.md, “Setting up bitcoind”, step 6, and docs/monero.md,
“Setting up monerod and monero-wallet-rpc”, start-up check 5): an address shown to the customer of an order the restore
lost must never go to a new order, because a late payment to it would mark that order paid.
Restarts
Section titled “Restarts”Every long-running service has restart: unless-stopped: Docker restarts it when it exits, and after the daemon or the
host restarts, but not after a manual stop. on-failure would also restart a failed start, but “doesn’t restart the
container if the daemon restarts” (Docker docs, engine/containers/start-containers-automatically.md): at start the
daemon restarts a container only if its policy says so for the last exit code (moby docker-v29.9.2 daemon/daemon.go:577
with daemon/internal/restartmanager/restartmanager.go:81-91), and SatBooth exits 0 when the daemon stops it, so it
would stay down after a reboot. After a daemon restart, the hub and the relay wait up to 5 s for netns, whose
namespace they join, to be started first (daemon/daemon.go:663-675, daemon/container.go:190-198; read in the
source, not observed).
SatBooth refuses to start while the hub does not answer (docs/running.md, “A supervisor”), so
while the hub is down Docker restarts it in a loop. Compose sets no delay for that loop outside Swarm: it passes only
the policy’s name and maximum retries to the engine, and drops deploy.restart_policy.delay and window
(docker/compose v5.6.1 pkg/compose/create.go:696-721). The engine, for every policy, waits 100 ms before the first
restart and doubles the wait up to 1 minute, and resets it to 100 ms when the container ran 10 s or more
(daemon/internal/restartmanager/restartmanager.go:12-14, :66-78; Docker docs, docker container run, “Restart
policies”). So the loop is as slow as each refused start. Observed on Engine 29.9.2, with a refused SatBooth container
under --restart on-failure (the same backoff):
| Case | Each start | Loop |
|---|---|---|
| the hub host does not answer (the connection to the relay times out) | 10 s: the NWC client’s bound on its first connection (nwc/client.go:192-193) |
one start every 10.2 s, about 350 an hour. Each container ran just over 10 s, so the wait went back to 100 ms every time; a few milliseconds less and it would grow to 1 minute |
| the relay refuses the connection at once | under 1 s | 7 starts in the first 10 s, then waits of 13, 26 and 51 s, then one start a minute |
the relay answers but the hub does not (get_info times out) |
the lower of nwc.timeout_seconds (at least 10) and 30 s |
one start per timeout plus 100 ms (not measured) |
A hub whose get_info names no network, or another network than nwc.network, refuses the start as soon as it has
answered (the loop of a relay that refuses at once). The invoice connection is checked first and the payout connection
only after it passes, so one start names one connection (reason invoice_connection or payout_connection); the cause
is in the error field of the satbooth failed line. The causes are the invoice connection's get_info does not state the network; pair it with satbooth-pair -connection invoice (payout in both places for the payout connection; the
connection was paired without “Read your node info”, whenever it was paired: on the connection’s page in the hub,
click Edit Connection, open Custom, tick “Read your node info” and click Save, without picking another permission
group; or pair it again), the invoice connection's hub is on the network "mainnet", not nwc.network "signet" (the two
names are printed only when the hub’s is one of mainnet, testnet, signet and regtest) and the invoice connection's hub reports a network SatBooth does not know. The start does not record the network in the data directory.
Each refused start logs memory locked; core dumps disabled and startup failed with its reason
(wallet_timeout, wallet_other, invoice_connection, …), and advances the pair stamp’s generation in both
databases (harmless: both files advance together). None of these refusals is an ALERT: line, so none reaches the
alert webhook. Watch the container’s restart count (docker inspect -f '{{.RestartCount}}') or its startup failed
lines. The one refusal that logs an ALERT: line on each start is bitcoind’s wallet behind SatBooth’s record (ALERT: wallet next_index is behind SatBooth's record; addresses are not issued, docs/bitcoind.md “Setting up bitcoind”); it
is queued for the alert webhook, but the alert deliverer starts only once every start-up check has passed, so it is sent
only after a start succeeds.
Documentation from SatBooth v0.1.0-rc.1, Apache-2.0