Skip to content

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:

  1. 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.
  2. Then deploy val-2 with JOIN_NETWORK=true as 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:

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

Note 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:26656
  • SEED_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

  1. Railway project → + New → GitHub Repo → this repo.
  2. Settings → Service Namesanect-val-2 (or any name; the lowered service name becomes the .railway.internal hostname).
  3. Settings → Source → Root Directory → leave blank (the root railway.json already points at docker/Dockerfile).
  4. Settings → Volume → Add → mount path /data. Required — without it the chain resets on every redeploy.
  5. 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:

env
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-2

If your existing node service has a different name on Railway, replace privacy-layer1 in SEED_PEER_HOST with the lowercased + hyphenated service name.

Step 3 — Deploy and wait for sync

  1. Deploy. Watch the logs.
  2. 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":"…"}
  3. The node will start syncing. With a young testnet this takes seconds to minutes. The heartbeat will start printing sanect | block=N once it's caught up.

Verify the join worked (run from your laptop or any RPC):

bash
# 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)

  1. Open the staking app at https://app.testnet.sanect.com.
  2. Connect Keplr with the operator key that holds your SNCT.
  3. Click "+ Register as validator" below the user panel.
  4. 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).
  5. Fill in moniker, self-delegation amount, commission settings → Submit & Sign in Keplr.
  6. 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:

bash
# 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 \
  --yes

Once 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

bash
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

SymptomCauseFix
New node shows catching_up: true foreverWrong SEED_PEER_HOST (private hostname doesn't resolve) or service is in a different Railway projectHostname is <lowercased-service-name>.railway.internal:26656. Both services must be in the same Railway project.
panic: app hash does not match after syncThe 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 usedTwo 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_BONDEDStake too small to crack top-50Either 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.