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
../rendezvous/SELF-HOSTING.md— running a relay../../docker/README.md— the images and how they are builtREFERENCE.md— the protocolTask.6.md— the deployment lane that produced all of this