# Regtest dev fixture for RTL: bitcoind + 3 LND nodes + RTL. # # NOT suitable for production. All credentials are throwaway. # # Topology is alice -> bob -> carol, so bob forwards payments and RTL's # routing/forwarding screens have data in them. See README.md. # # Node images come from Polar (https://lightningpolar.com), which publishes # multi-arch (amd64 + arm64) builds. Nothing is built locally. volumes: bitcoind_data: alice_data: bob_data: carol_data: cln_data: eclair_data: rtl_db: rtl_config: rtl_sso_db: rtl_sso_config: rtl_sso_cookie: x-lnd: &lnd image: polarlightning/lnd:0.20.0-beta restart: unless-stopped depends_on: - bitcoind services: bitcoind: container_name: ${COMPOSE_PROJECT_NAME}_bitcoind image: polarlightning/bitcoind:30.0 restart: unless-stopped command: - bitcoind - -server=1 - -regtest=1 # rpcauth hash for ${BITCOIN_RPC_USER}/${BITCOIN_RPC_PASSWORD}. '$$' escapes # compose interpolation and reaches bitcoind as a single '$'. - -rpcauth=rtldev:8a1f2c3d4e5b6a7c8d9e0f1a2b3c4d5e$$010df4b32c5e9a556cba1857eb5865990c983d8a56dadc0fdbf457cf90073c6c - -zmqpubrawblock=tcp://0.0.0.0:${BITCOIN_ZMQ_BLOCK_PORT} - -zmqpubrawtx=tcp://0.0.0.0:${BITCOIN_ZMQ_TX_PORT} # eclair's zmqblock consumes the hashblock topic, not rawblock (LND uses # rawblock/rawtx above); without this endpoint eclair never sees new # blocks and channels stay in WAIT_FOR_FUNDING_CONFIRMED forever. - -zmqpubhashblock=tcp://0.0.0.0:${BITCOIN_ZMQ_HASHBLOCK_PORT:-28336} - -txindex=1 - -dnsseed=0 - -rpcbind=0.0.0.0 - -rpcallowip=0.0.0.0/0 - -rpcport=${BITCOIN_RPC_PORT} - -listen=1 - -listenonion=0 - -fallbackfee=0.0002 ports: - "${BITCOIN_RPC_PORT}:${BITCOIN_RPC_PORT}" volumes: - bitcoind_data:/home/bitcoin/.bitcoin # --alias / --externalip / --tlsextradomain are per-node on purpose: the alias # is what RTL displays, and the extradomain must match the hostname RTL dials # (https://alice:8080) or TLS validation fails. alice: <<: *lnd container_name: ${COMPOSE_PROJECT_NAME}_alice command: - lnd - --noseedbackup - --trickledelay=5000 - --alias=alice - --externalip=alice - --tlsextradomain=alice - --tlsextradomain=${COMPOSE_PROJECT_NAME}_alice - --tlsextradomain=host.docker.internal - --listen=0.0.0.0:${LIGHTNING_P2P_PORT} - --rpclisten=0.0.0.0:${LIGHTNING_RPC_PORT} - --restlisten=0.0.0.0:${LIGHTNING_REST_PORT} - --bitcoin.active - --bitcoin.regtest - --bitcoin.node=bitcoind - --bitcoind.rpchost=${BITCOIN_HOST}:${BITCOIN_RPC_PORT} - --bitcoind.rpcuser=${BITCOIN_RPC_USER} - --bitcoind.rpcpass=${BITCOIN_RPC_PASSWORD} - --bitcoind.zmqpubrawblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_BLOCK_PORT} - --bitcoind.zmqpubrawtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT} - --accept-keysend - --accept-amp ports: - "${ALICE_REST_PORT}:${LIGHTNING_REST_PORT}" volumes: - alice_data:/home/lnd/.lnd bob: <<: *lnd container_name: ${COMPOSE_PROJECT_NAME}_bob command: - lnd - --noseedbackup - --trickledelay=5000 - --alias=bob - --externalip=bob - --tlsextradomain=bob - --tlsextradomain=${COMPOSE_PROJECT_NAME}_bob - --tlsextradomain=host.docker.internal - --listen=0.0.0.0:${LIGHTNING_P2P_PORT} - --rpclisten=0.0.0.0:${LIGHTNING_RPC_PORT} - --restlisten=0.0.0.0:${LIGHTNING_REST_PORT} - --bitcoin.active - --bitcoin.regtest - --bitcoin.node=bitcoind - --bitcoind.rpchost=${BITCOIN_HOST}:${BITCOIN_RPC_PORT} - --bitcoind.rpcuser=${BITCOIN_RPC_USER} - --bitcoind.rpcpass=${BITCOIN_RPC_PASSWORD} - --bitcoind.zmqpubrawblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_BLOCK_PORT} - --bitcoind.zmqpubrawtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT} - --accept-keysend - --accept-amp ports: - "${BOB_REST_PORT}:${LIGHTNING_REST_PORT}" volumes: - bob_data:/home/lnd/.lnd carol: <<: *lnd container_name: ${COMPOSE_PROJECT_NAME}_carol command: - lnd - --noseedbackup - --trickledelay=5000 - --alias=carol - --externalip=carol - --tlsextradomain=carol - --tlsextradomain=${COMPOSE_PROJECT_NAME}_carol - --tlsextradomain=host.docker.internal - --listen=0.0.0.0:${LIGHTNING_P2P_PORT} - --rpclisten=0.0.0.0:${LIGHTNING_RPC_PORT} - --restlisten=0.0.0.0:${LIGHTNING_REST_PORT} - --bitcoin.active - --bitcoin.regtest - --bitcoin.node=bitcoind - --bitcoind.rpchost=${BITCOIN_HOST}:${BITCOIN_RPC_PORT} - --bitcoind.rpcuser=${BITCOIN_RPC_USER} - --bitcoind.rpcpass=${BITCOIN_RPC_PASSWORD} - --bitcoind.zmqpubrawblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_BLOCK_PORT} - --bitcoind.zmqpubrawtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT} - --accept-keysend - --accept-amp ports: - "${CAROL_REST_PORT}:${LIGHTNING_REST_PORT}" volumes: - carol_data:/home/lnd/.lnd # Core Lightning node. Unlike the LND nodes it talks to RTL over clnrest (the # built-in REST plugin) using rune auth, so it needs --clnrest-* options and a # rune written where RTL can read it. create-rune.sh (run from the healthcheck) # creates the rune and writes it to /root/.lightning/rtl.rune (RTL mounts that # read-only); the healthcheck is unhealthy until it exists, so rtl waits for it. # --clnrest-host=0.0.0.0 is required so the rtl container can reach it; the default # 127.0.0.1 would only be reachable from inside this container. Protocol stays https # (clnrest default, self-signed) — RTL connects with rejectUnauthorized:false. cln: image: elementsproject/lightningd:v25.09 container_name: ${COMPOSE_PROJECT_NAME}_cln restart: unless-stopped depends_on: - bitcoind environment: LIGHTNINGD_NETWORK: regtest command: - --alias=cln - --bitcoin-rpcconnect=${BITCOIN_HOST} - --bitcoin-rpcport=${BITCOIN_RPC_PORT} - --bitcoin-rpcuser=${BITCOIN_RPC_USER} - --bitcoin-rpcpassword=${BITCOIN_RPC_PASSWORD} - --bitcoin-retry-timeout=3600 - --addr=0.0.0.0:9735 - --announce-addr=cln:9735 - --large-channels - --clnrest-host=0.0.0.0 - --clnrest-port=3010 ports: - "${CLN_REST_PORT:-3010}:3010" volumes: - cln_data:/root/.lightning - ./cln/create-rune.sh:/opt/create-rune.sh:ro healthcheck: # create-rune.sh ensures the rune exists (idempotent, one quick attempt) and # this reports healthy only once it does. Driving it from the healthcheck — which # retries on its interval — means a transient RPC-startup race self-heals instead # of a one-shot script permanently wedging the stack. rtl waits on this via # depends_on: condition: service_healthy before reading the rune at startup. test: ["CMD-SHELL", "sh /opt/create-rune.sh && test -f /root/.lightning/rtl.rune"] interval: 5s timeout: 10s retries: 40 # Eclair has no on-chain wallet of its own -- it drives a bitcoind wallet over # RPC. Without a dedicated wallet it would grab "the default loaded wallet", # which here is the rtldev mining wallet, and eclair's on-chain balance would # show the miner's coins. This init container creates (or reloads) a wallet # named "eclair" before the eclair node starts; load_on_startup survives # bitcoind restarts. eclair-wallet-init: container_name: ${COMPOSE_PROJECT_NAME}_eclair_wallet_init image: polarlightning/bitcoind:30.0 depends_on: - bitcoind entrypoint: ["/bin/sh", "-c"] command: - | bcli() { bitcoin-cli -regtest -rpcconnect=${BITCOIN_HOST} -rpcport=${BITCOIN_RPC_PORT} -rpcuser=${BITCOIN_RPC_USER} -rpcpassword=${BITCOIN_RPC_PASSWORD} "$$@"; } i=0 until bcli getblockchaininfo >/dev/null 2>&1; do i=$$((i+1)); [ "$$i" -ge 60 ] && echo "bitcoind never came up" && exit 1 sleep 1 done bcli -named createwallet wallet_name=eclair load_on_startup=true >/dev/null 2>&1 \ || bcli loadwallet eclair true >/dev/null 2>&1 \ || true bcli -rpcwallet=eclair getwalletinfo >/dev/null echo "eclair wallet ready" # Eclair node. Talks to RTL over its HTTP API with basic auth (the # lnApiPassword in RTL's config). The polarlightning image is used because # acinq/eclair on Docker Hub is amd64-only and its newest versioned tag is # years stale; Polar builds the same ACINQ source multi-arch (amd64 + arm64). # The image entrypoint translates each --key=value into -Declair.key=value. # The entrypoint also overrides server.public-ips.0 with the container IP, but # the arg must still be present for that substitution to happen. eclair: image: polarlightning/eclair:0.13.1 container_name: ${COMPOSE_PROJECT_NAME}_eclair restart: unless-stopped depends_on: bitcoind: condition: service_started eclair-wallet-init: condition: service_completed_successfully command: - polar-eclair - --node-alias=eclair - --server.public-ips.0=eclair - --server.port=9735 - --api.enabled=true - --api.binding-ip=0.0.0.0 - --api.port=8080 - --api.password=${ECLAIR_API_PASSWORD:-rtldev} - --chain=regtest - --bitcoind.host=${BITCOIN_HOST} - --bitcoind.rpcport=${BITCOIN_RPC_PORT} - --bitcoind.rpcuser=${BITCOIN_RPC_USER} - --bitcoind.rpcpassword=${BITCOIN_RPC_PASSWORD} # zmqblock must be bitcoind's *hashblock* endpoint (eclair subscribes to # that topic); pointing it at the rawblock endpoint the LND nodes use # leaves eclair blind to new blocks. - --bitcoind.zmqblock=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_HASHBLOCK_PORT:-28336} - --bitcoind.zmqtx=tcp://${BITCOIN_HOST}:${BITCOIN_ZMQ_TX_PORT} - --bitcoind.wallet=eclair - --datadir=/home/eclair/.eclair - --printToConsole=true # Regtest feerates are far from mainnet estimates; without a wide # tolerance eclair closes channels over feerate disagreements. - --on-chain-fees.feerate-tolerance.ratio-low=0.00001 - --on-chain-fees.feerate-tolerance.ratio-high=10000.0 ports: - "${ECLAIR_REST_PORT:-8281}:8080" volumes: - eclair_data:/home/eclair # RTL rewrites its config file on startup, so it cannot be given the tracked # template directly: a bind mount would either be read-only (RTL exits with # EROFS) or would let RTL scribble into a version-controlled file. Instead the # template is copied into a volume that 'down -v' discards, which keeps the # source pristine and every run starting from identical config. rtl-config-init: container_name: ${COMPOSE_PROJECT_NAME}_rtl_config_init image: busybox:1.36 command: > sh -c "cp /template/RTL-Config.regtest.json /config/RTL-Config.json && chmod 644 /config/RTL-Config.json && echo 'config staged'" volumes: - ./rtl/RTL-Config.regtest.json:/template/RTL-Config.regtest.json:ro - rtl_config:/config rtl: container_name: ${COMPOSE_PROJECT_NAME}_rtl # Defaults to the published image; override with RTL_IMAGE (e.g. a locally built # branch image) to test unreleased changes: RTL_IMAGE=rtl:pr1625 docker compose up. image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.10} restart: unless-stopped depends_on: rtl-config-init: condition: service_completed_successfully alice: condition: service_started bob: condition: service_started carol: condition: service_started cln: condition: service_healthy eclair: condition: service_started ports: - "${RTL_PORT}:${RTL_PORT}" environment: RTL_CONFIG_PATH: /RTL/config volumes: - rtl_config:/RTL/config - alice_data:/lnd/alice:ro - bob_data:/lnd/bob:ro - carol_data:/lnd/carol:ro - cln_data:/cln:ro - rtl_db:/RTL/database # --------------------------------------------------------------------------- # BTCPay Server SSO harness -- profile "sso", so a plain 'up' does not start it: # # docker compose --profile sso up -d # bin/sso-url # # BTCPay bundles RTL as a service and runs it in single-sign-on mode. RTL # writes a random cookie to RTL_COOKIE_PATH; BTCPay reads that file and renders # a link to /rtl/api/authenticate/cookie?access-key= on its Services # page. That path is not a registered route -- it falls through to RTL's # catch-all, which mints the XSRF-TOKEN cookie and serves index.html, and the # SPA then reads access-key from the query string and posts it as a password # login. Reproducing that entry path is the whole point of this harness: it is # where RTL's auth, CSRF and static-serving behaviour meet an external caller, # and it has broken before without the standalone flow noticing. # # BTCPay itself (postgres + nbxplorer + btcpayserver) is deliberately not here. # See README.md, "BTCPay SSO harness", for what that leaves untested and how to # run against a real BTCPay when it matters. # --------------------------------------------------------------------------- # Same copy-into-a-volume dance as rtl-config-init above, and for the same # reason: RTL rewrites its config on startup. rtl-sso-config-init: container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso_config_init profiles: ["sso"] image: busybox:1.36 command: > sh -c "cp /template/RTL-Config.sso.json /config/RTL-Config.json && chmod 644 /config/RTL-Config.json && echo 'sso config staged'" volumes: - ./rtl/RTL-Config.sso.json:/template/RTL-Config.sso.json:ro - rtl_sso_config:/config # A second RTL rather than a flag on the first: RTL picks one authentication # mode at startup, so SSO and the password login cannot coexist in one # instance. Only alice is wired up, matching BTCPay's one-node-per-RTL layout. # # The environment block is copied from BTCPay's own compose fragment # (docker-compose-generator/docker-fragments/bitcoin-lnd.yml in # btcpayserver-docker), so this exercises the env-driven SSO path BTCPay # actually uses rather than the config-file equivalent -- which is why # RTL-Config.sso.json leaves its SSO block zeroed and carries no multiPass. # # Only 'expose'd, never published: all access goes through the proxy, as it # does under BTCPay. rtl-sso: container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso profiles: ["sso"] image: ${RTL_IMAGE:-shahanafarooqui/rtl:v0.15.10} restart: unless-stopped depends_on: rtl-sso-config-init: condition: service_completed_successfully alice: condition: service_started environment: RTL_CONFIG_PATH: /RTL/config RTL_SSO: 1 RTL_COOKIE_PATH: /RTL/cookie/.cookie LOGOUT_REDIRECT_LINK: /server/services expose: - "3000" volumes: - rtl_sso_config:/RTL/config - rtl_sso_cookie:/RTL/cookie - alice_data:/lnd/alice:ro - rtl_sso_db:/RTL/database # Stands in for BTCPay's traefik. Routes only /rtl and /rtl/*, mirroring the # label BTCPay puts on its RTL container; see nginx/rtl-sso.conf. rtl-sso-proxy: container_name: ${COMPOSE_PROJECT_NAME}_rtl_sso_proxy profiles: ["sso"] image: nginx:1.27-alpine restart: unless-stopped depends_on: - rtl-sso ports: - "${RTL_SSO_PORT:-3001}:80" volumes: - ./nginx/rtl-sso.conf:/etc/nginx/conf.d/default.conf:ro