Self-hosting a tracker

Run the discovery service: install, configure, expose, and prove it is answering.

Mirrored from docs/tracker/SELF-HOSTING.md.Source last changed Edit docs/tracker/SELF-HOSTING.md

Running a Nodera tracker

Category: tracker · Last audit: 2026-07-27

A tracker is the discovery half of the network: peers announce worlds to it and query it, so a world's peer list survives the host peer going offline. It holds no authority — a lying tracker can hide peers or list unreachable ones; it cannot forge a world (hash-verified), an identity (Ed25519-signed) or a vote. Losing every tracker degrades discovery and never correctness.

That is what makes running one a low-stakes thing to do, and why the project wants more of them than it runs itself.


1. The short version

mkdir nodera && cd nodera
curl -O https://raw.githubusercontent.com/Ashu11-A/NoderaMC/main/docker/compose.yml
curl -o .env https://raw.githubusercontent.com/Ashu11-A/NoderaMC/main/docker/.env.example
$EDITOR .env          # the three required values
docker compose up -d

Then open 6969/tcp and 6969/udp in your firewall, and check it from somewhere else:

docker run --rm ghcr.io/ashu11-a/noderamc:tracker-latest \
    nodera-tracker --healthcheck your-host.example.org:6969

That command speaks the real protocol. A plain nc or a port scan proves the socket is open, which is not the same as the service answering.

2. The images

ghcr.io/ashu11-a/noderamc:tracker-latest      the newest release tag
ghcr.io/ashu11-a/noderamc:tracker-canary      built from every push to main
ghcr.io/ashu11-a/noderamc:tracker-sha-abc1234 one specific commit — pin this if you want a
                                              deployment that does not move under you

Both latest and canary are multi-architecture manifest lists covering linux/amd64 and linux/arm64; Docker picks the right one, including on a Raspberry Pi or an Ampere VM. They are built by .github/workflows/containers.yml, which builds each architecture on its own native runner and then merges them — and fails if the finished manifest does not actually contain both.

The image is alpine with one statically linked binary in it, about 10 MB. There is no shell script wrapper, no supervisor, and no package manager left in the runtime layer.

3. Configuration

Every setting is an environment variable, named NODERA_TRACKER_ plus the TOML key, uppercased. A config file still works and still beats the defaults; it is simply no longer required.

Precedence: defaults → config file → environment → command-line flag.

Ask the binary rather than trusting this document:

docker run --rm ghcr.io/ashu11-a/noderamc:tracker-latest nodera-tracker --print-env

The one you must set

Variable Why
NODERA_TRACKER_ADVERTISED_ROUTES The public host:port peers should use. Empty falls back to the bind address, which inside a container is 0.0.0.0:6969 and is useless to anyone outside it. Comma-separate to advertise several routes — a name and a literal address survives a DNS outage.

docker/compose.yml refuses to start without it rather than starting something that looks healthy and is unreachable.

The rest

Variable Default What it does
NODERA_TRACKER_BIND_ADDR 0.0.0.0:6969 in the image Listen address. The binary's own default is 25600; 6969 is the image's convention so the development scripts are unaffected.
NODERA_TRACKER_IDENTITY_FILE /var/lib/nodera/tracker-identity.bin The service signing key. Keep this.
NODERA_TRACKER_PERSIST_DIR /var/lib/nodera/state World display names. Peer state is never persisted.
NODERA_TRACKER_UDP_ENABLED true Serve the same requests over UDP on the same port.
NODERA_TRACKER_ANNOUNCE_INTERVAL_SECONDS 120 The cadence handed back in every ack — the tracker paces announce traffic, not the peer.
NODERA_TRACKER_PEER_TTL_SECONDS 300 How long a record survives without a refresh. Must be ≥ the interval, or the world list flickers empty no matter how healthy the swarm is.
NODERA_TRACKER_MAX_WORLDS 10000
NODERA_TRACKER_MAX_PEERS_PER_WORLD 5000
NODERA_TRACKER_PER_IP_ANNOUNCE_QUOTA 60 Announces accepted per source IP per interval.
NODERA_TRACKER_MAX_SERVICES 256 Size of the service directory. Far smaller than MAX_WORLDS on purpose: a network has a handful of infrastructure hosts and thousands of worlds, so a directory at world scale would be evidence of abuse rather than success.
NODERA_TRACKER_PER_IP_REPORT_QUOTA 30 Score reports accepted per source IP per interval.
NODERA_TRACKER_SERVICE_REPORT_MAX_REPORTERS 32 How many identities an attacker needs before they can move a service's median score.
NODERA_TRACKER_PEER_TRACKER_ENDPOINTS empty Other trackers to announce this one to, so peers can discover it. Empty is normal for a single-tracker deployment.
NODERA_TRACKER_UPDATE_CHANNEL empty — off See §6.
NODERA_TRACKER_TELEMETRY_ENDPOINT empty — off Windowed counters only; never a peer, a world or an address.

An unrecognised NODERA_TRACKER_* variable refuses the start. NODERA_TRACKER_MAX_SERVICE (missing the S) looks exactly as authoritative in a compose file as the real name, and a service that ignored it would run with a bound you believe is in force and is not.

4. Things that will bite you

The identity file is the service. It lives in /var/lib/nodera, and a tracker that regenerates it presents itself to the network as a brand-new, unmeasured host after every restart — precisely when the availability score it has earned matters most. Mount a volume. The compose file does.

Stop timeouts. SIGTERM starts a drain: refuse new work, tell the peers, tell the other trackers, wait for what is in flight. The default grace is 30 seconds and docker stop allows 10, so without stop_grace_period every deploy kills the service mid-drain. The compose file sets 45s; on the CLI use docker stop -t 45.

A container cannot reach its own host's public address. If you run a relay beside this tracker from the same compose file, point the relay at tcp://tracker:6969 — the compose service name. The public address has to hairpin back through the published port and is refused on a default bridge network.

UDP is worth publishing. It costs one round trip where TCP costs a handshake, which matters to a peer sweeping many trackers on a cadence. Publishing only TCP works and peers fall back; the service says so at startup if the UDP bind fails.

5. Upgrading

docker compose pull && docker compose up -d

That is the intended path in a container: SIGTERM drains, the new image starts, and the contents are something you can verify against a tag. Leave NODERA_TRACKER_UPDATE_CHANNEL empty.

6. Self-update, and why it is off

Set NODERA_TRACKER_UPDATE_CHANNEL=latest and the service will notice when the published binary is not the one it is running, verify the replacement against the release's SHA-256 manifest, drain, swap and re-exec.

It is empty by default and that means off, because downloading a tracker is agreement to run a tracker, not agreement to let it replace its own executable. Same rule as the telemetry lane.

Inside a container it is the wrong tool anyway: it asks the image to rewrite its own executable, and if it succeeds you have a container whose contents no longer match its tag. Use §5.

Provenance

The service also fetches SHA256SUMS.sig and verifies it against a pinned Ed25519 key before it reads any digest, so a substituted manifest cannot choose the binary. A missing signature is a refusal rather than a fallback — otherwise deleting one asset from a release turns the check off.

No key is pinned yet. DEFAULT_RELEASE_PUBLIC_KEY is empty, the check is skipped, and the service says so on every check. Until it is set, the digest proves integrity and not provenance (L-81): whoever can publish to the release can publish a matching digest.

For the project (or a fork) to close that:

openssl genpkey -algorithm ED25519 -out release-signing.pem     # keep this offline
gh secret set NODERA_RELEASE_SIGNING_KEY < release-signing.pem
# the public half, as the 64 hex characters DEFAULT_RELEASE_PUBLIC_KEY wants:
openssl pkey -in release-signing.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32

A fork with its own releases sets NODERA_TRACKER_UPDATE_RELEASE_PUBLIC_KEY instead of editing the constant — but note that a trust root an attacker can set in a config file is not a trust root, so that variable is for deliberately trusting somebody else's releases, not for convenience.

7. Getting listed

Once it has been up for a while, open a pull request adding it to index.json on the services branch — against services, not main. One entry looks like this, and kind, name and endpoints are the only required fields:

{
  "kind": "tracker",
  "name": "example-eu",
  "endpoints": ["tcp://tracker.example.org:6969", "udp://tracker.example.org:6969"],
  "node_id": "17324454ccfaa3398210b81910392675",
  "operator": "you <[email protected]>",
  "region": "eu-central",
  "notes": "Free text, shown to anyone who opens the entry in the app."
}

endpoints are routes to try, best first — at most eight, each tcp:// or udp:// with an explicit port. More than one is normal and useful: a name plus a literal address survives a DNS outage, and an IPv4 plus an IPv6 route survives a client that has only one of them. A host running both a tracker and a relay publishes two entries; they are separate services, on separate ports, with separate identities. The node_id is optional — take it from your startup line:

nodera-tracker: service identity 17324454ccfaa3398210b81910392675

Publishing it turns a hijacked DNS name from something a peer accepts into something a peer notices.

8. Without Docker

The release page publishes a glibc Linux binary per architecture, named nodera-tracker-<arch>-<version> (x64 or arm64; <version> is latest for the rolling prerelease, or the tag), with a SHA256SUMS covering every asset beside it:

arch=x64        # or arm64
base=https://github.com/Ashu11-A/NoderaMC/releases/download/latest
curl -LO "$base/nodera-tracker-$arch-latest"
curl -LO "$base/SHA256SUMS"
sha256sum --check --ignore-missing SHA256SUMS
mv "nodera-tracker-$arch-latest" nodera-tracker
chmod +x nodera-tracker && ./nodera-tracker --config nodera-tracker.toml

Every environment variable above works the same way. The self-update lane asks for the asset matching the architecture it was compiled for, so a binary moved to a plain nodera-tracker on disk still updates correctly — the name on disk is yours, the name in the release is not. For musl, build from source or use the image.

See also