What you'll end up with
A server that keeps a full copy of the BlockDAG chain, stays in sync with the network, and answers questions about it over the internet. Wallets like MetaMask can point at it. Scripts and applications can read from it.
You'll also have a firewall that allows only what's needed, and HTTPS without buying a certificate or opening a single inbound port.
Budget a full afternoon. Most of it is waiting.
What you need
Hardware:
- 4 CPU cores and 16 GB RAM. Less works; this is comfortable.
- NVMe storage, at least 100 GB free. The chain data passes 20 GB and grows daily. The node writes constantly, so disk speed matters more than processor speed.
- Ubuntu or Debian, with root access.
Connection: bandwidth barely matters — a node uses kilobits per second, not megabits. Uptime matters. So does whether your IP address changes, which is covered below.
Also: a domain name and a free Cloudflare account.
Where the server sits
Location determines who your node serves well. From Europe, European clients see tens of milliseconds and North American ones 115 to 180 ms. For RPC that's fine — a wallet makes a handful of calls and nobody notices 150 ms.
It would not be fine for a mining pool. At 197 ms round-trip, a miner loses roughly 35% of submitted work to shares arriving after their job expired. Same hardware on a local pool loses none. If you ever plan to run a pool, put it near your miners.
Running it from home on a dynamic connection
This works, with one important distinction.
For the RPC endpoint, a changing IP doesn't matter at all. The Cloudflare Tunnel used later in this guide makes an outbound connection from your server to Cloudflare, and traffic flows back down that connection. Cloudflare never needs to know your address. When your ISP changes it, the tunnel reconnects and nothing breaks. No dynamic DNS, no port forwarding, no router configuration.
For anything that needs a direct connection, it does matter. A mining pool's stratum port can't go through the tunnel — stratum is raw TCP, not HTTP. That needs an A record pointing at your real IP, which breaks when the IP changes. For that you'd need a static IP from your ISP, or a dynamic DNS client updating the record automatically.
So: RPC and a web explorer from a home connection, yes, comfortably. A mining pool from a dynamic home connection, only with extra work.
One more thing about home hosting: a power cut costs you more than the reboot. See the header rebuild section below.
Step 1: Check what you're working with
Before installing anything, confirm the machine is up to the job.
Disk space:
df -hLook at the row mounted on /. You want well over 100 GB in the Avail column.
Memory and cores:
free -h
nprocStep 2: Install Docker
The node ships as Docker containers.
sudo apt update
sudo apt install -y docker.io docker-compose-v2
sudo systemctl enable --now dockerConfirm:
sudo docker run --rm hello-worldA welcome message means Docker works. systemctl enable matters — without it Docker won't start after a reboot, and neither will your node.
Step 3: Set up the firewall
Do this before the node is running and reachable, not after.
A firewall decides which connections from outside your server accepts. A fresh server accepts everything, which is more than you want.
sudo apt install -y ufwAllow SSH before enabling anything. Skip this and you lock yourself out, with no way back except your provider's console.
sudo ufw allow 22/tcp comment 'SSH'If you always connect from the same network, restrict it:
sudo ufw allow from 192.168.1.0/24 to any port 22 proto tcp comment 'SSH from LAN'Now the one port the node genuinely needs open — peer-to-peer, how it talks to other nodes and stays in sync:
sudo ufw allow 8150/tcp comment 'BDAG P2P'Enable it:
sudo ufw enable
sudo ufw statusTwo rules. That's everything a public RPC node needs open.
Why the RPC ports stay closed
Your node listens on 18545, 18546 and 38131. It's tempting to open them so the world can reach them. Don't.
The Cloudflare Tunnel connects outbound from your server. Nothing needs to be open inbound. You get HTTPS, rate limiting and DDoS protection for free, and your server's real address stays private.
Opening 18545 directly means no HTTPS, no rate limiting, your IP exposed in every DNS lookup, and a port anyone can hammer.
Docker can bypass ufw
This one catches people out. When a container publishes a port with ports: in the compose file, Docker writes firewall rules that take effect before ufw's. A port you never allowed can be reachable from the internet.
Check what's actually listening:
sudo ss -tlnpRead the Local Address column:
127.0.0.1:5432— loopback only, unreachable from outside the machine. Safe.0.0.0.0:8150or*:8150— every interface including the public one. Exposed.
If something is on 0.0.0.0 that shouldn't be public, change its compose mapping from 5432:5432 to 127.0.0.1:5432:5432 and restart that container. The database in this stack should be bound to loopback — check it.
Adding rules later
Each service is a decision. A dashboard you only use yourself doesn't need to be public:
sudo ufw allow from 192.168.1.0/24 to any port 8088 proto tcp comment 'Dashboard LAN only'Removing one:
sudo ufw delete allow 8088/tcpKeep the list short. Every open port is something you've decided to defend.
When something can't connect, read the error
Two failures look similar and mean opposite things. Test from the machine that's trying to connect:
nc -zv 192.168.1.X 3334- Connection refused — packets arrived, nothing is listening, or the service is down. Not a firewall problem.
- Timeout, or nothing at all — packets are being dropped. That's a firewall.
Knowing which saves you from editing firewall rules to fix a stopped container.
Step 4: Get the release
The release is published on the BlockDAG 2.1.0-rc.2 release page. It does not hand you a fixed link; it builds the download command for you:
- Choose your path: Existing node to upgrade beside the data you already have, or New node to start from the chain snapshot.
- Choose the architecture of the machine that will run it: AMD64 (x86-64) or ARM64.
- Choose what to download: the full runtime ZIP, or one component (Core, Pool, Dashboard, Stack). For this guide, the full runtime.
- Copy the command it shows. Each download lists its CID, path and SHA-256, and the command checks the SHA-256 for you. The page keeps downloads locked until it has verified its own release record, so wait for that before copying.
Run the command in a permanent directory and unpack the archive there:
sudo mkdir -p /opt/bdag
cd /opt/bdag
# unpack the release archive hereIt contains a node, a mining pool, a database and a dashboard. You don't need all of it — for an RPC node, the node and its database are enough.
Everything below assumes you're inside the unpacked directory.
Step 5: Download the snapshot — don't skip this
Your node needs the chain's entire history. It can fetch it block by block from peers, but at current chain length that takes about 18 days.
A snapshot is a pre-packaged copy. It gets you within hours of current in roughly half an hour.
Newer snapshot: snapshot.dagcore.net
DagCore publishes its own snapshots there, taken from a node DagCore runs, so your node starts much closer to the tip than it does from the official one of 7 September described below. They are not an official BlockDAG release, and they come in a different format: a .tar of the node's node-data/mainnet folder, not a .bdsnap file. If you use one, skip the rest of this step and step 6: follow the verify and unpack steps on that page, then continue from step 7.
About 13 GB, published in 13 parts:
for i in $(seq -f "%03g" 1 13); do
wget -c "https://github.com/BlockdagEngineering/bdag-ipfs-release-page/releases/download/jeremy%2Fdistribution%2F2.1.0-rc.2-install-v1/blockdag-chain1404-order20821036-20260907.bdsnap.part-$i"
done-c resumes if the connection drops. Run it again if something fails partway — it picks up where it stopped.
Join them:
cat blockdag-chain1404-order20821036-20260907.bdsnap.part-{001..013} \
> blockdag-chain1404-order20821036-20260907.bdsnapVerify before using it. A corrupt 13 GB file costs hours of confused debugging:
sha256sum blockdag-chain1404-order20821036-20260907.bdsnapExpected:
8f7b093b73a7fe390d53d275f5d4b7d69d32aea96220b19a3e2cc54804a5d608Not a close match — an exact one. If it differs, download again.
You now have both the parts and the joined file, so you're using 26 GB. Delete the parts once the checksum passes:
rm blockdag-chain1404-order20821036-20260907.bdsnap.part-*Snapshot URLs change with each release. If the link above is dead, the release page offers the current snapshot under New node, with its checksum.
Verify the checksum published alongside whichever file you get. Don't reuse the one above for a different snapshot.
Step 6: Put the snapshot where the node expects it
The node reads the snapshot from a path inside its container. Find out which:
grep -i snapshot docker-compose.yml
grep -i -i snapshot .envLook for a mount that maps a local file into the container — typically the file sits in the stack directory and appears inside as /snapshot/latest.bdsnap.
Rename your download to match what the compose file expects:
mv blockdag-chain1404-order20821036-20260907.bdsnap latest.bdsnapSome builds also need a switch in .env:
grep -i "USE_SNAPSHOT\|SNAPSHOT" .envIf there's a USE_SNAPSHOT setting, set it to yes before first start.
After the node is running you can confirm exactly what it sees:
sudo docker inspect node --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'That prints every file and directory mounted into the container. You'll see the snapshot path and, importantly, where the chain data actually lives. That second one is worth noting now — it's what you back up, and what you'd copy to move the node elsewhere.
Note: chain data is often a bind mount to a directory in the stack folder, not a Docker volume. docker volume ls won't show it, and its size won't appear in docker system df. If you're trying to work out what's eating your disk, check the mount paths, not the volume list.
Step 7: Start the node
sudo docker compose up -d node postgresWatch it:
sudo docker logs -f nodeCtrl+C stops watching; the node keeps running.
Step 8: Confirm the snapshot actually imported
Two ways to tell.
In the logs, look for snapshot activity near startup:
sudo docker logs node 2>&1 | grep -i snapshot | head -20In the block numbers, which is the definitive check. Ask the node what block it's on:
curl -s -X POST -H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
http://127.0.0.1:18545You get hexadecimal back — something like 0x141a48f. Convert it:
printf "%d\n" 0x141a48fIf the result is in the millions, the snapshot imported. If it's in the hundreds or thousands, it didn't, and your node is syncing from genesis — stop it now and sort the snapshot out rather than waiting 18 days.
Watch the chain data grow as confirmation:
du -sh <the chain data path from step 6>Step 9: The header rebuild, which looks like a crash
After the snapshot import — and again after a restart — the node runs an upgrade rebuild header scan. It takes 10 to 15 minutes, and while it runs everything looks broken:
- RPC returns
503 Too busy. Try again later. - Logs fill with
mining readiness probe failed - Anything depending on the node reports it unreachable
Nothing is wrong. Check the progress:
sudo docker logs --tail 100 node 2>&1 | grep "upgrade rebuild" | tail -1You'll see current_layer climbing toward to_layer with an ETA. Wait.
This is the single most misleading thing about running this node. People restart, see 503 everywhere, assume the restart broke something, restart again, and start the rebuild over.
Plan around it: a reboot costs roughly 15 minutes of downtime beyond container start time. At home, that's every power cut. A small UPS is worth more than any hardware upgrade here.
Step 10: Confirm it's actually synced
Several checks, from quickest to most thorough.
The age field
Every block the node processes gets a log line with an age field — how old that block was when processed:
sudo docker logs --tail 20 nodeCatching up shows ages of hours or days, falling steadily. age=5mo3w2d means you're five months behind. age=2s means you're current.
When the node reaches the head, the age field may stop appearing altogether — there's no lag left to report.
Compare against the network
Get your height:
curl -s -X POST -H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
http://127.0.0.1:18545Convert from hex and compare against a public explorer or another public RPC. Within a handful of blocks is synced.
How far ahead your peers are
The clearest signal. The node writes status lines containing p2p_best_peer_lead_blocks:
sudo docker logs --tail 100 node 2>&1 | grep -o "p2p_best_peer_lead_blocks=[0-9]*" | tail -1- A number in the millions: you're far behind, still catching up.
- Zero or single digits: you're at the head.
If this line stops appearing entirely, that's good — the warning it belongs to only prints when something is lagging.
Two separate sync states
The node tracks native sync (the DAG) and EVM sync (the Ethereum-compatible layer) separately. They can differ. The node publishes a JSON status line with both:
sudo docker logs --tail 200 node 2>&1 | grep node_status_log | tail -1Look for native_sync and evm_sync, each with a status. You want both showing synced. evm_sync: synced with native_sync: unknown usually means the DAG side is busy — often the header rebuild.
Peer count
curl -s -X POST -H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","method":"net_peerCount","params":[],"id":1}' \
http://127.0.0.1:18545Hex again. A healthy node holds a dozen or more peers. Zero or one means port 8150 isn't reachable — check the firewall and, if you're behind a router, port forwarding.
Expect constant reorganisations
Your logs will show DAG REORGANIZE and Rewind every few seconds. Normal for this chain — a block at the tip gets replaced. Almost always one block deep, occasionally up to four.
It matters if you build anything assuming a block is permanent the moment you see it. It isn't. Anything that indexes this chain should stay a margin behind the head — a dozen blocks covers observed behaviour comfortably.
Step 11: Decide what the node exposes
Two files control it: .env and node.conf.
In node.conf:
# The node's own RPC
rpclisten=0.0.0.0:38131
# Ethereum-compatible RPC — what wallets use
evm.http.addr=0.0.0.0
evm.http.port=18545
evm.http.api=eth,net,web3
evm.ws.addr=0.0.0.0
evm.ws.port=18546
# Credentials
rpcuser=pick_a_username
rpcpass=a_long_random_stringevm.http.api is the important one. It lists which command families the node answers. Defaults often include debug and txpool. Remove both before going public — a single debug call can tie up your node for a long time, and on a free endpoint someone will eventually try one.
Keep eth, net and web3. Be aware web3_* doesn't respond on this build even when listed — web3_clientVersion returns error -32601. Don't advertise it.
Generate real credentials:
openssl rand -hex 24Once per secret. Never leave placeholders in a config file.
Check what actually responds
Config says one thing; the node does another. Test:
for m in eth_chainId eth_blockNumber eth_gasPrice net_version net_peerCount; do
echo -n "$m: "
curl -s -X POST -H "Content-Type: application/json" \
--data "{\"jsonrpc\":\"2.0\",\"method\":\"$m\",\"params\":[],\"id\":1}" \
http://127.0.0.1:18545
echo
doneAnd for methods needing arguments, call them with none — the error tells you whether they exist:
for m in eth_getBalance eth_call eth_getLogs eth_sendRawTransaction; do
echo -n "$m: "
curl -s -X POST -H "Content-Type: application/json" \
--data "{\"jsonrpc\":\"2.0\",\"method\":\"$m\",\"params\":[],\"id\":1}" \
http://127.0.0.1:18545 | head -c 120
echo
done- -32602, missing value for required argument — the method exists, you just didn't pass arguments. Good.
- -32601, method does not exist — not available.
Do this before you document your endpoint. Listing a method that doesn't answer wastes someone else's afternoon.
Restarting the node to change config triggers another header rebuild. Get the config right in one pass.
Step 12: Put it online with a Cloudflare Tunnel
A tunnel gives you a public HTTPS address without opening any inbound port — and, on a home connection, without caring what your IP is.
Install cloudflared per Cloudflare's current instructions, then:
cloudflared tunnel loginA browser opens; pick your domain.
cloudflared tunnel create mynodeIt prints a tunnel ID and writes a credentials file. Keep both.
Write /etc/cloudflared/config.yml:
tunnel: your-tunnel-id-here
credentials-file: /root/.cloudflared/your-tunnel-id-here.json
ingress:
- hostname: rpc.example.com
service: http://127.0.0.1:18545
- service: http_status:404YAML indentation is strict: two spaces before - hostname, four before service.
Rules are read top to bottom, and http_status:404 matches everything. It must be last, or nothing below it is ever reached. Adding a hostname after it is the most common mistake here — the route exists, validates fine, and never fires.
cloudflared tunnel ingress validate
sudo systemctl enable --now cloudflaredThe DNS record
In Cloudflare's DNS section:
- Type: CNAME
- Name:
rpc - Target:
your-tunnel-id-here.cfargotunnel.com - Proxy status: Proxied — the orange cloud
Two mistakes that cost hours:
DNS only instead of Proxied. Visitors get the tunnel's internal placeholder address, something starting fd10:, unreachable from the internet. The symptom is a site dead from everywhere while working perfectly on the server. If a visitor's curl says Network is unreachable on an fd10: address, this is why.
An A record to your public IP instead of a CNAME to the tunnel. Cloudflare tries port 443 on your server, where nothing listens.
If your tunnel is configured from a local config.yml, Cloudflare's dashboard shows it as locally managed and won't let you add routes there. Routes go in the file; only the DNS record goes in the dashboard.
Test from somewhere else — this matters
curl -s -X POST -H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
https://rpc.example.comRun this from a different machine, or your phone on mobile data.
Testing from the server itself is misleading. If the server's hostname matches the domain, /etc/hosts resolves it to 127.0.1.1 and curl never leaves the machine — you get connection refused on port 443 and learn nothing about whether the tunnel works.
Check what a real client sees:
getent hosts rpc.example.comPublic Cloudflare addresses mean the record is right. Anything in fd10: means Proxied is off.
Step 13: Rate limiting
A public endpoint without a limit eventually meets a script calling it in a loop.
Security → WAF → Rate limiting rules → Create rule:
- Match: hostname equals
rpc.example.com - Threshold: 20 requests per 10 seconds
- Counting by: IP
- Action: Block, shortest available duration
Normal use doesn't come close — a wallet makes a few calls per page load.
On the free plan the hostname field may be missing from the rule builder, leaving only URI Path and a couple of others. URI Path equals / works as a fallback. It catches more than intended, but at this threshold no real visitor notices.
Verify you haven't broken normal use:
for i in $(seq 1 5); do
curl -s -o /dev/null -w "%{http_code} " -X POST \
-H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
https://rpc.example.com
done; echoFive 200s and you're fine.
Step 14: Make sure it survives a reboot
Docker starts at boot if you enabled it in step 2. Containers come back only if the compose file says so — check for restart: unless-stopped on each service:
grep -i restart docker-compose.ymlunless-stopped means containers return after a reboot, except ones you stopped by hand. That's the behaviour you want.
Confirm Docker itself is enabled:
systemctl is-enabled docker
systemctl is-enabled cloudflaredBoth should say enabled.
The real test is a reboot, but do it when you can afford the header rebuild afterwards.
What you have now
An Ethereum-compatible JSON-RPC endpoint on the public internet, behind HTTPS, rate limited, with no inbound ports open beyond peer-to-peer.
The full eth_* family works, plus net_version and net_peerCount. Enough for ethers.js, viem, web3.py and MetaMask.
Wallet setup: chain ID 1404 (0x57c), symbol BDAG, 18 decimals.
Things that will confuse you later
Mining rewards don't appear as transactions. They're credited directly to a balance by consensus. An address can show a large balance with an empty transaction history and nothing is wrong. The same applies to claimed funds. If you build an explorer, this is the first thing users will report as a bug — and you'll spend an evening looking for a transaction that was never there.
The node is pruned. Historical state isn't kept. eth_getBalance at a block even 10,000 back returns missing trie node. You can read what an address holds now, not what it held last month. Anything needing history has to be built from indexed transactions — with the caveat above, that this misses rewards entirely.
Two networks share chain ID 1404, with different block histories. The same address shows different balances on each and neither is wrong — they're separate chains. Say clearly which one your endpoint serves, or people will think they've lost money.
The block reward is split. Nominal is 233.227204970 BDAG; 163.259043479 reaches the miner. The other 30% goes to staking at protocol level. Anything calculated from the nominal figure is wrong by nearly a third.
Internal transfers are invisible over standard RPC. Funds moved by a contract rather than a plain transaction don't appear in transaction lists. Tracing them needs debug_traceTransaction, which you disabled — for good reason.
When something breaks
RPC returns 503 Too busy — almost always the header rebuild. Check with the grep in step 9 and wait.
Peer count is zero or one — port 8150 isn't reachable. Check ufw, and port forwarding if you're behind a router.
Site unreachable everywhere but works locally — DNS record set to DNS only instead of Proxied. Check with getent hosts.
404 from Cloudflare on a hostname you configured — the ingress route is missing or sits below http_status:404. Routes are read top to bottom.
Connection refused — something's listening but rejecting, or the service is stopped. Not a firewall. Check docker compose ps.
Timeout — packets dropped. That is a firewall, either yours or an upstream one.
A container keeps restarting — read its logs, not the node's:
sudo docker compose logs --tail 30 <service>Node seems stuck — compare block height a minute apart. If it's not moving, check peers first, then disk space.
Based on one working installation on chain 1404, September 2026. Release versions, snapshot URLs and network parameters change. Verify against current sources before relying on any specific value here.