Node guide · chain ID 1404

Running a public BlockDAG RPC node

A complete account of setting up a BlockDAG node on chain 1404 and turning it into a free public JSON-RPC endpoint — including the parts that look like failures but aren't, and the mistakes that cost hours.

Written for someone who has never done it before. You need to be able to connect to a Linux server over SSH and copy commands into a terminal. Everything else is explained.

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:

bash
df -h

Look at the row mounted on /. You want well over 100 GB in the Avail column.

Memory and cores:

bash
free -h
nproc

Step 2: Install Docker

The node ships as Docker containers.

bash
sudo apt update
sudo apt install -y docker.io docker-compose-v2
sudo systemctl enable --now docker

Confirm:

bash
sudo docker run --rm hello-world

A 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.

bash
sudo apt install -y ufw

Allow SSH before enabling anything. Skip this and you lock yourself out, with no way back except your provider's console.

bash
sudo ufw allow 22/tcp comment 'SSH'

If you always connect from the same network, restrict it:

bash
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:

bash
sudo ufw allow 8150/tcp comment 'BDAG P2P'

Enable it:

bash
sudo ufw enable
sudo ufw status

Two 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:

bash
sudo ss -tlnp

Read the Local Address column:

  • 127.0.0.1:5432 — loopback only, unreachable from outside the machine. Safe.
  • 0.0.0.0:8150 or *: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:

bash
sudo ufw allow from 192.168.1.0/24 to any port 8088 proto tcp comment 'Dashboard LAN only'

Removing one:

bash
sudo ufw delete allow 8088/tcp

Keep 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:

bash
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:

  1. Choose your path: Existing node to upgrade beside the data you already have, or New node to start from the chain snapshot.
  2. Choose the architecture of the machine that will run it: AMD64 (x86-64) or ARM64.
  3. Choose what to download: the full runtime ZIP, or one component (Core, Pool, Dashboard, Stack). For this guide, the full runtime.
  4. 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:

bash
sudo mkdir -p /opt/bdag
cd /opt/bdag
# unpack the release archive here

It 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:

bash
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:

bash
cat blockdag-chain1404-order20821036-20260907.bdsnap.part-{001..013} \
  > blockdag-chain1404-order20821036-20260907.bdsnap

Verify before using it. A corrupt 13 GB file costs hours of confused debugging:

bash
sha256sum blockdag-chain1404-order20821036-20260907.bdsnap

Expected:

text
8f7b093b73a7fe390d53d275f5d4b7d69d32aea96220b19a3e2cc54804a5d608

Not 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:

bash
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:

bash
grep -i snapshot docker-compose.yml
grep -i -i snapshot .env

Look 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:

bash
mv blockdag-chain1404-order20821036-20260907.bdsnap latest.bdsnap

Some builds also need a switch in .env:

bash
grep -i "USE_SNAPSHOT\|SNAPSHOT" .env

If 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:

bash
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

bash
sudo docker compose up -d node postgres

Watch it:

bash
sudo docker logs -f node

Ctrl+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:

bash
sudo docker logs node 2>&1 | grep -i snapshot | head -20

In the block numbers, which is the definitive check. Ask the node what block it's on:

bash
curl -s -X POST -H "Content-Type: application/json" \
  --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
  http://127.0.0.1:18545

You get hexadecimal back — something like 0x141a48f. Convert it:

bash
printf "%d\n" 0x141a48f

If 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:

bash
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:

bash
sudo docker logs --tail 100 node 2>&1 | grep "upgrade rebuild" | tail -1

You'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:

bash
sudo docker logs --tail 20 node

Catching 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:

bash
curl -s -X POST -H "Content-Type: application/json" \
  --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
  http://127.0.0.1:18545

Convert 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:

bash
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:

bash
sudo docker logs --tail 200 node 2>&1 | grep node_status_log | tail -1

Look 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

bash
curl -s -X POST -H "Content-Type: application/json" \
  --data '{"jsonrpc":"2.0","method":"net_peerCount","params":[],"id":1}' \
  http://127.0.0.1:18545

Hex 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:

ini
# 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_string

evm.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:

bash
openssl rand -hex 24

Once per secret. Never leave placeholders in a config file.

Check what actually responds

Config says one thing; the node does another. Test:

bash
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
done

And for methods needing arguments, call them with none — the error tells you whether they exist:

bash
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:

bash
cloudflared tunnel login

A browser opens; pick your domain.

bash
cloudflared tunnel create mynode

It prints a tunnel ID and writes a credentials file. Keep both.

Write /etc/cloudflared/config.yml:

yaml
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:404

YAML 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.

bash
cloudflared tunnel ingress validate
sudo systemctl enable --now cloudflared

The 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

bash
curl -s -X POST -H "Content-Type: application/json" \
  --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' \
  https://rpc.example.com

Run 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:

bash
getent hosts rpc.example.com

Public 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:

bash
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; echo

Five 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:

bash
grep -i restart docker-compose.yml

unless-stopped means containers return after a reboot, except ones you stopped by hand. That's the behaviour you want.

Confirm Docker itself is enabled:

bash
systemctl is-enabled docker
systemctl is-enabled cloudflared

Both 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:

bash
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.