Operations › Run a node
This is the procedure used to add a machine to the chain as a full node: it downloads and re-executes every block from genesis and serves its own JSON-RPC, but it does not sign blocks. It was written down while the second host was prepared on 2 Oct 2026, so the next node can be built the same way. The compose file is the one that host runs; image, genesis and gas floor are the same on every node.
What you need
| Machine | 2 vCPU, 4 GB RAM, 20 GB disk is enough today (the chain data is under 2 GB). Linux with Docker and the compose plugin. |
|---|---|
| Client | hyperledger/besu:26.2.0 — pin this exact version; every node of the network runs it. |
| Genesis | https://btcw.tech/genesis.json · sha256 686cc9ad2df9ba2ce9ca479cb01d551e454bd85f4b3c3707762f66d0a1385fea |
| Genesis block hash | 0x4d4c7675b66bff6e0a36a4cc628bd13ada6c83d53bc72cd8692de146604ebc9d — a node started from the right file reports exactly this for block 0. |
| A peer | The enode URL of a node that is already in sync, and a network path to its P2P port (TCP 30303). The existing nodes are not reachable from the Internet and discovery is off, so a peer is always added by hand — see Peers. |
1. Files
mkdir -p /opt/btcw-node/chain && cd /opt/btcw-node
curl -s https://btcw.tech/genesis.json -o chain/genesis.json
sha256sum chain/genesis.json # must print 686cc9ad…5fea
echo '[]' > chain/static-nodes.json # peers go here, step 3
2. Compose file
/opt/btcw-node/docker-compose.yml. The node uses the host network and listens on 127.0.0.1 only: Docker's published
ports bypass ufw, so nothing is published at all.
services:
besu-node:
image: hyperledger/besu:26.2.0
restart: unless-stopped
network_mode: host
environment:
BESU_OPTS: -Xmx1536m
command:
- --data-path=/var/lib/besu
- --genesis-file=/config/genesis.json
- --profile=ENTERPRISE
- --sync-mode=FULL
- --min-gas-price=1000000
- --discovery-enabled=false
- --static-nodes-file=/config/static-nodes.json
- --p2p-host=127.0.0.1
- --p2p-interface=127.0.0.1
- --p2p-port=30303
- --rpc-http-enabled
- --rpc-http-host=127.0.0.1
- --rpc-http-port=28545
- --rpc-http-api=ETH,NET,WEB3
- --host-allowlist=localhost,127.0.0.1
volumes:
- ./chain/genesis.json:/config/genesis.json:ro
- ./chain/static-nodes.json:/config/static-nodes.json:ro
- node-data:/var/lib/besu
mem_limit: 2560m
logging: { driver: json-file, options: { max-size: "20m", max-file: "3" } }
volumes:
node-data:
--sync-mode=FULL | Re-executes every block. The chain is small; snapshot sync is not needed and not used by any node. |
|---|---|
--min-gas-price=1000000 | The network's gas floor (0.001 gwei). A node with another value accepts or drops different transactions than its peers. |
--profile=ENTERPRISE | Besu's profile for private networks: no assumptions about public-network peers. |
--rpc-http-api=ETH,NET,WEB3 | Read and send only. ADMIN, DEBUG, TXPOOL and QBFT stay off on a node others can query. |
| Storage format | Default (Bonsai). Only a node that serves debug_* / trace_* to an explorer needs --data-storage-format=FOREST. |
3. Peers
On a node that is already in sync, read its enode URL (net_enode is part of the NET API):
curl -s -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"net_enode","params":[]}' http://127.0.0.1:28545
Put it in chain/static-nodes.json on the new node, with the host and port the new node can actually reach:
["enode://<128 hex characters>@<host>:<port>"]
- The connection is one TCP stream (RLPx). It works in either direction, so only one side has to be reachable.
- Between two machines, carry it over a private path (a VPN or an SSH port forward restricted to that one port), not an open port.
In that case
<host>:<port>is the local end of the forward, e.g.127.0.0.1:30311. - Peer with a full node, never directly with the validator: the validator has no published port and no public RPC.
- The 128-character node id must be the id of the node at the other end — the handshake fails otherwise.
4. Start and check
docker compose up -d besu-node
rpc() { curl -s -H 'content-type: application/json' -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$1\",\"params\":$2}" http://127.0.0.1:28545; echo; }
rpc eth_chainId '[]' # 0x75b48
rpc eth_getBlockByNumber '["0x0",false]' # "hash":"0x4d4c7675…bc9d"
rpc net_peerCount '[]' # 0x1 or more once a peer is configured
rpc eth_blockNumber '[]' # climbs until it matches the public RPC, then +1 every 5 seconds
The node is in sync when the hash of a recent block equals the one the public RPC returns for the same number:
cast block 100000 --field hash --rpc-url http://127.0.0.1:28545
cast block 100000 --field hash --rpc-url https://rpc.btcw.tech
With no peer configured the first two checks already pass and the block number stays at 0 — that state proves the genesis file and the flags are right before any connection is made.
Pitfalls met in practice
- "Supplied file does not contain valid keyPair pair" — only for a node started with
--node-private-key-file. The image's entrypoint starts as root and then drops to uid 1000, so a key file readable only by root cannot be read. Give it to uid 1000 (chown 1000:1000, mode600). The same applies to a data volume copied from another machine. - "Host not authorized" — the request's
Hostheader is not in--host-allowlist. Query throughlocalhost/127.0.0.1, or send-H 'Host: localhost'from a proxy. - Moving a node between machines — stop it first (
docker stop -t 60), copy the data volume, compare a checksum of the archive on both sides, and keep the stopped container on the old machine until the new one has produced or imported blocks. - Never run two nodes with the same node key at the same time — for a validator this means two signers of the same blocks.
eth_getLogs— Besu limits the range to about 1,000 blocks per call; indexers must page (see JSON-RPC).
Full node or validator
A full node needs nobody's approval and changes nothing for the rest of the network. A validator is different: QBFT needs
ceil(2N/3) of the N validators online to produce a block.
| Validators | Needed online | Can be offline |
|---|---|---|
| 1 (today) | 1 | 0 |
| 2 | 2 | 0 — either machine stopping halts the chain, and a halted chain cannot vote a validator out |
| 3 | 2 | 1 |
| 4 | 3 | 1, and the set also tolerates one validator misbehaving |
So the validator set goes from 1 straight to 3 or 4 machines, never to 2. A validator is added by the existing validators voting for its
address (qbft_proposeValidatorVote on their own RPC, which is not public); the change takes effect once more than half of them
have voted. Read the current set from any node with the QBFT API: qbft_getValidatorsByBlockNumber("latest").