Self-hosting a rendezvous point
Run the NAT-traversal service: hole punching, relay fallback, and what it costs to carry other people's traffic.
Mirrored from docs/rendezvous/SELF-HOSTING.md.Source last changed Edit docs/rendezvous/SELF-HOSTING.md
Running a Nodera rendezvous / relay
Category: rendezvous · Last audit: 2026-07-28
A relay is the reachability half of the network. Most players are behind a NAT that will not accept an inbound connection; a relay lets two such peers meet, and bridges an end-to-end-encrypted circuit between them when no direct path exists.
It holds no authority and cannot read what it carries. Records are self-signed and verified by the discovering peer against the same canonical bytes; circuit payloads are encrypted end to end. A lying relay can refuse to forward or hide records. It cannot forge one, and it cannot read a byte of what passes through it.
Running one costs bandwidth, which is the honest reason there are fewer of these than trackers.
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
docker compose up -d rendezvous
Open 7500/tcp, and check it from elsewhere:
docker run --rm ghcr.io/ashu11-a/noderamc:rendezvous-latest \
nodera-rendezvous --healthcheck your-host.example.org:7500
2. The images, upgrading, and the update lane
ghcr.io/ashu11-a/noderamc:rendezvous-latest the newest release tag
ghcr.io/ashu11-a/noderamc:rendezvous-canary built from every push to main
ghcr.io/ashu11-a/noderamc:rendezvous-sha-abc1234 one specific commit — pin this if you want a
deployment that does not move under you
latest and canary are multi-architecture manifest lists covering linux/amd64 and
linux/arm64, so Docker picks the right one on a Raspberry Pi or an Ampere VM as well. The image is
alpine with one statically linked binary in it, about 10 MB: no wrapper script, no supervisor, no
package manager left in the runtime layer.
Upgrading is docker compose pull && docker compose up -d. That is the intended path in a
container: SIGTERM drains, the new image starts, and what is running is something you can verify
against a tag.
Self-update is off, and empty means off. Setting NODERA_RENDEZVOUS_UPDATE_CHANNEL=latest makes
the service notice a newer published binary, verify it against the release's SHA-256 manifest,
drain, swap and re-exec. Downloading a relay is agreement to run a relay, not agreement to let it
replace its own executable — and inside a container it is the wrong tool anyway, because if it
succeeds you have a container whose contents no longer match its tag.
Two things about that lane are worth knowing before you enable it outside a container. The manifest
is checked against a pinned Ed25519 key before any digest is read, and a missing signature is a
refusal rather than a fallback — but no key is pinned yet, so until one is, the digest proves
integrity and not provenance (L-81). And NODERA_RENDEZVOUS_UPDATE_CHANNEL requires
NODERA_RENDEZVOUS_TRACKER_ENDPOINTS, refusing to start without it: a relay that updates itself has
to tell peers where to go while it drains, and it learns about replacement relays from its trackers.
Updating without that is a relay that vanishes mid-transfer.
3. Configuration
NODERA_RENDEZVOUS_ plus the TOML key, uppercased. Full list:
docker run --rm ghcr.io/ashu11-a/noderamc:rendezvous-latest nodera-rendezvous --print-env
The two you must set
| Variable | Why |
|---|---|
NODERA_RENDEZVOUS_ADVERTISED_ROUTES |
The public host:port peers should use. Inside a container the bind address is 0.0.0.0:7500, which is useless to anyone outside it. |
NODERA_RENDEZVOUS_TRACKER_ENDPOINTS |
The tracker(s) this relay announces itself to. A relay that announces nowhere is reachable only by peers that already have its address, which is the discovery lane not working. Running a tracker from the same compose file? Use tcp://tracker:6969 — see §4. |
Worth setting
| Variable | Default | What it does |
|---|---|---|
NODERA_RENDEZVOUS_RESERVATION_HMAC_KEY_HEX |
empty | Keeps relay reservation proofs valid across a restart. Empty mints an ephemeral key at boot — correct, but every outstanding reservation is invalidated whenever the process restarts. openssl rand -hex 32. |
NODERA_RENDEZVOUS_MAX_CONCURRENT_CIRCUITS |
0 (unstated) |
Advertised headroom, not an enforced cap — the enforcement is the reservation limits. Publishing it lets peers prefer a relay with room over a saturated one. 0 is honest for a host you have not measured. |
NODERA_RENDEZVOUS_DRAIN_GRACE_SECONDS |
30 |
How long a drain waits for live circuits. Raising it past compose's stop_grace_period achieves nothing — Docker kills the container first. |
NODERA_RENDEZVOUS_RESERVATION_MAX_BYTES |
67108864 |
Byte ceiling for one circuit. This is your bandwidth bill. |
NODERA_RENDEZVOUS_RESERVATION_MAX_DURATION_SECONDS |
600 |
Wall-clock ceiling for one circuit. |
NODERA_RENDEZVOUS_CIRCUIT_IDLE_TIMEOUT_SECONDS |
60 |
No bytes either way for this long tears a circuit down. |
NODERA_RENDEZVOUS_PER_IP_REQUEST_QUOTA |
120 |
Register/reserve per source IP per refresh interval. |
NODERA_RENDEZVOUS_MAX_RECORDS_PER_NAMESPACE |
5000 |
|
NODERA_RENDEZVOUS_IDENTITY_FILE |
/var/lib/nodera/rendezvous-identity.bin |
Keep this. |
An unrecognised NODERA_RENDEZVOUS_* variable refuses the start, for the reason given in the
tracker guide.
Three names in this prefix belong to the Java peer, not to this service —
NODERA_RENDEZVOUS_ENDPOINTS, _FANOUT, _SWEEP_SECONDS — and are ignored here rather than
refused, so a shell holding both can start both.
4. Things that will bite you
Announce to a tracker, or nobody finds you. This is the single most common way a working relay ends up unused.
If the tracker is in the same compose file, use its service name. A container reaching its own
host's public address has to hairpin back through the published port, and on a default bridge
network that is refused — you get Connection refused in the announce line and nothing else. Use
tcp://tracker:6969.
Stop timeouts matter more here than for a tracker. A drain waits for bridged circuits carrying real transfers, and cutting one costs a peer its download rather than a query it will retry. The compose file allows 60s.
A drain that times out still cuts. The grace period is bounded because an unbounded wait lets one stuck circuit hang a restart forever. Making that cost a re-dial instead of the transfer needs resumable transfers, which is an open limitation (L-83) rather than something a longer wait fixes.
The identity file is the service. A regenerated one presents this relay to every peer as brand new with no measured availability.
5. Getting listed
Open a pull request against the services branch — not main — adding an entry to
index.json:
{
"kind": "rendezvous",
"name": "example-eu",
"endpoints": ["tcp://relay.example.org:7500"],
"node_id": "9f2c1e40b7a34d5581cc0e77a1b93d02",
"operator": "you <[email protected]>",
"region": "eu-central"
}
kind, name and endpoints are required; the rest is optional. node_id is the 32 hex
characters from your startup line, and publishing it turns a hijacked DNS name from something a peer
accepts into something a peer notices. If you run a tracker on the same host, that is a second
entry with "kind": "tracker" — separate service, separate port, separate identity.
Run it for a while before you open the PR. A relay that appears and disappears is worse for peers than one that was never listed, because they will have measured it and preferred it.
See also
../tracker/SELF-HOSTING.md— running a tracker../../docker/README.md— the images and how they are builtREFERENCE.md— the protocolTask.6.md— the deployment lane