Add validator 2 (and beyond) on Railway
Important: Don't just "duplicate" the running service. The new container would either (a) start a fresh genesis on an empty volume = its own separate chain, or (b) somehow share the volume = both nodes signing with the same key = double-sign slash + tombstone. Each new validator needs its own Railway service, its own volume, and its own unique consensus key.
The Dockerfile in this repo supports a JOIN_NETWORK=true mode that takes care of fetching the live genesis, wiring peers, and generating a fresh unique key for you. Adding a new Railway validator becomes a 4-minute checklist.
Important if your chain has been running a while
The entrypoint uses CometBFT state sync to skip past the old block history — without it a brand-new validator would full-sync from block 0 (at 500 ms blocks that's hours per million blocks).
State sync needs the seed (val-1) to serve snapshots — which is off in default Cosmos SDK builds. This repo's entrypoint now enables snapshot serving on every node, but it takes effect only after each node is redeployed.
Two-stage flow if val-1 has been running for a while:
- Redeploy val-1 once so it picks up the new entrypoint. Wait ~17 minutes for it to produce two snapshots (one every 1000 blocks). No data loss — the volume is preserved.
- Then deploy val-2 with
JOIN_NETWORK=trueas described below. It will state-sync to a recent height in seconds instead of full-syncing for hours.
If you skipped step 1, val-2 will fall back to full sync and just take much longer. The end state is identical either way.
Prereqs (one-time, on your laptop)
Get the existing validator's CometBFT node id and the internal Railway hostname of the existing node service. Pick whichever bullet matches your setup:
# From the public RPC of your existing node:
curl -s https://rpc.testnet.sanect.com/rpc/status \
| jq -r '.result.node_info.id'
# -> e.g. a0ae54bdf174183dae20b4fad79a81b4e5281454Note Railway's internal hostname for your existing node service — it's <service-name>.railway.internal. If your existing service is named sanect-node, the hostname is privacy-layer1.railway.internal. Service names are lower-cased and hyphenated for DNS.
You'll need:
SEED_NODE_ID= the node id from above (40-char hex)SEED_PEER_HOST=<service>.railway.internal:26656SEED_NODE_URL= the existing service's public domain (used once to fetch genesis):https://rpc.testnet.sanect.com
Add val-2 (then repeat for val-3 … val-7)
Step 1 — New Railway service
- Railway project → + New → GitHub Repo → this repo.
- Settings → Service Name →
sanect-val-2(or any name; the lowered service name becomes the.railway.internalhostname). - Settings → Source → Root Directory → leave blank (the root
railway.jsonalready points atdocker/Dockerfile). - Settings → Volume → Add → mount path
/data. Required — without it the chain resets on every redeploy. - Settings → Networking → Generate Domain is optional — only needed if you want this validator to also serve public RPC. Validators don't need a public domain to participate.
Step 2 — Set the join env vars
Settings → Variables → Raw Editor → paste:
JOIN_NETWORK=true
# These come from "Prereqs" above.
SEED_NODE_URL=https://rpc.testnet.sanect.com
SEED_NODE_ID=<paste the 40-char node id from /rpc/status>
SEED_PEER_HOST=privacy-layer1.railway.internal:26656
# Identity for this validator (visible in monitoring + on-chain description).
MONIKER=val-2If your existing node service has a different name on Railway, replace
privacy-layer1inSEED_PEER_HOSTwith the lowercased + hyphenated service name.
Step 3 — Deploy and wait for sync
- Deploy. Watch the logs.
- You'll see the new entrypoint do its work:
>>> First boot: JOINING existing chain sanect_76287-1 via … >>> Fetching live genesis from … >>> Wiring peers: <node-id>@privacy-layer1.railway.internal:26656 >>> This node's CometBFT id: <new-unique-id> >>> This node's consensus pubkey: {"@type":"…ed25519…","key":"…"} - The node will start syncing. With a young testnet this takes seconds to minutes. The heartbeat will start printing
sanect | block=Nonce it's caught up.
Verify the join worked (run from your laptop or any RPC):
# Should match the seed's height (within a block or two)
curl -s https://rpc.testnet.sanect.com/rpc/status \
| jq -r .result.sync_info.latest_block_height
# vs the new validator's local status (Railway logs show it, or curl localhost in a shell)Step 4 — Become an actual validator (submit MsgCreateValidator)
You have two ways to do this — the staking app (no CLI) or sanectd.
Option A — Use the staking app (no CLI needed)
- Open the staking app at
https://app.testnet.sanect.com. - Connect Keplr with the operator key that holds your SNCT.
- Click "+ Register as validator" below the user panel.
- Paste the consensus pubkey JSON from val-2's Railway logs (the line that starts with
>>> This node's consensus pubkey:— copy the full{"@type":"…","key":"…"}object). - Fill in moniker, self-delegation amount, commission settings → Submit & Sign in Keplr.
- Keplr opens, you confirm, the tx broadcasts. On success the modal shows the txhash and the validator appears in the table within a block.
Option B — sanectd CLI
Until this step, the new node is a synced full node, not an active validator. To bond it into the top-50 active set:
# On your laptop with `sanectd` installed and a funded operator key.
# 1. Grab the new node's consensus pubkey from Step 3's logs
# (the line starting `>>> This node's consensus pubkey:`).
PUBKEY='{"@type":"/cosmos.crypto.ed25519.PubKey","key":"BASE64..."}'
# 2. Submit the create-validator tx (uses your operator key, NOT the
# consensus key — they are separate).
sanectd tx staking create-validator \
--amount=2000000000000000000000asnct \
--pubkey="$PUBKEY" \
--moniker="val-2" \
--chain-id=sanect_76287-1 \
--node=https://rpc.testnet.sanect.com/rpc \
--commission-rate=0.10 \
--commission-max-rate=0.20 \
--commission-max-change-rate=0.01 \
--min-self-delegation=100000000000000000000 \
--from=mykey \
--keyring-backend=file \
--gas=auto --gas-adjustment=1.4 \
--gas-prices=10000000asnct \
--yesOnce the tx commits, the new validator immediately enters the active set (if its stake puts it in the top 50) and starts signing the next block.
Step 5 — Verify it's active
curl -s https://rpc.testnet.sanect.com/rest/cosmos/staking/v1beta1/validators?status=BOND_STATUS_BONDED \
| jq -r '.validators[] | "\(.description.moniker) \(.tokens)"'You should see two lines now — your original validator and val-2.
Repeating for val-3 … val-50
Same exact 5-step playbook. The only things that change between services:
MONIKER(e.g.val-3,val-4, …)- (Optional) Adding each new validator's internal hostname as another peer for all the others, so the topology becomes a mesh instead of a star. This is optional — CometBFT's PEX (peer-exchange, on by default) will discover other peers through the seed automatically, just slower.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
New node shows catching_up: true forever | Wrong SEED_PEER_HOST (private hostname doesn't resolve) or service is in a different Railway project | Hostname is <lowercased-service-name>.railway.internal:26656. Both services must be in the same Railway project. |
panic: app hash does not match after sync | The SEED_NODE_URL returned a different chain's genesis (you pointed at the wrong domain) | Verify SEED_NODE_URL matches the chain you want to join. Wipe the volume and redeploy. |
MsgCreateValidator tx returns consensus pubkey already used | Two new validators ended up with the same key (shouldn't happen with our entrypoint, but possible if you copied a volume) | The new validator must have its own freshly-generated key. Wipe its volume so the entrypoint regenerates. |
New validator never appears in validators?status=BOND_STATUS_BONDED | Stake too small to crack top-50 | Either delegate more to it, or you must wait for another validator to be replaced. Below-the-line validators are "bonded but not in active set" — they don't sign. |
Val-2 logs show indexed block events height=66917 but the chain is at 1M+ | Full-syncing from block 0 because state sync wasn't available | (a) Fast fix: redeploy val-1 to enable snapshot serving, wait ~17 min for snapshots, then wipe val-2's volume and redeploy with JOIN_NETWORK=true — it'll state-sync to tip in seconds. (b) Slow fix: wait. Full sync will finish eventually; estimate a few hours per million blocks at modest hardware. |
Why not just numReplicas: 50 on the one service?
Because every replica would mount the same code, the same env vars, and every replica would generate or load the same priv_validator_key.json. When consensus runs, all 50 would sign the same block at the same height with the same key → equivocation evidence → slash_fraction_double_sign = 5% + tombstoned = true (permanent removal). The Cosmos chain's slashing module literally exists to punish exactly this scenario.
This is why the public docs (Run a validator, 1 → 50 validators on Railway) repeat the rule: validators are separate services, replicas are only safe for read-only RPC nodes that have no bonded signing key.