Reaching your node
A client never guesses where you are. It reads your node record on chain,
gets a base URL like https://node1.yourname.algo.xyz:9090, resolves that
hostname in DNS, dials the resulting IP on that port, and completes a TLS
handshake against a certificate issued for that name. Those four values live in
three places, and they must keep agreeing after setup: a cloud instance restarts
onto a fresh IP, a certificate expires, or you change a port and forget the URL
on chain. tls.mode: acme and ip_sync keep them in sync.
This page assumes you've already registered an operator record
(see Registering on-chain), that you own an NFD, and that
algod.network is testnet or mainnet. NFD-backed DNS and ACME are not
available on localnet.
The four things that must agree
| What | Where it lives | Who keeps it right |
|---|---|---|
| Hostname → IP | An A/AAAA record in your NFD's u.dns field | You once with zs-node nfd-dns, or the ip_sync loop continuously |
| Certificate | The node's ACME cache on disk | tls.mode: acme, renewing in the background |
| Base URL | Your node record on chain, keyed by (operator id, node id) | The operator dashboard once, or ip_sync.url continuously |
| Port | The base URL only — never a DNS record | server.listen |
DNS resolves a name to an address and nothing more. The scheme and port a
client connects on come from the base URL on chain, so u.dns only ever needs a
single A record, and you should never encode a port there.
Turning on tls.mode: acme and ip_sync together collapses the four into
one value you control. Your NFD name plus zs.nfd_record_name derives the
hostname; the hostname derives the certificate; the hostname plus
server.listen derives the base URL; and the loop keeps the DNS record pointed
at wherever the machine is. At the defaults, a node that moves to a new IP is
reachable again in about two minutes with no action from you, and certificates
renew automatically.
Pick your node's hostname
NFD names are published under a public DNS suffix that depends on the network:
| Network | An NFD named yourname.algo is reachable as |
|---|---|
mainnet | yourname.algo.xyz |
testnet | yourname.dotalgo.io |
localnet | Not supported — ACME and ip_sync refuse to start |
Leave zs.nfd_record_name empty and your node lives at that apex. To be
reachable at node1.yourname.algo.xyz instead, you have two routes:
- Set
zs.nfd_record_name: node1. The node writes a namedA/AAAArecord under thenode1label inside your existing NFD'su.dns. No second NFD needed. - Own the segment NFD
node1.yourname.algoand pointzs.nfd_app_idat it, leaving the label empty. The node then publishes at that NFD's apex.
Either way, the certificate domain and the base URL written on chain follow automatically.
The label is a bare DNS label, and the node validates it strictly:
- Lowercase letters, digits, and internal hyphens. Uppercase is rejected, not silently lowercased, because record-name comparisons downstream are case-sensitive.
- Dotted labels are fine for a deeper name (
api.node1). - A literal
@is rejected; leave the value empty for the apex. - A value ending in
.algois rejected. That's an NFD name, and it belongs inzs.nfd_app_id. - It is validated on every boot, even with TLS off.
Keep it short. The base URL on chain has a hard 248-byte ceiling, and a long label eats into it.
One label per node
Two nodes must never publish at the same record. If you run two nodes under
one operator and leave both at the NFD apex, each node's ip_sync loop believes
it owns the apex A record and rewrites it by removing and re-adding. Node B
overwrites node A's address, node A's next tick overwrites it back, and so on
forever. Each tick costs one on-chain transaction per node, and clients reach
whichever node wrote last.
Every update is also a read-modify-write of your NFD's entire u.dns
document, so two racing writers can drop each other's unrelated records,
including a live _acme-challenge TXT record mid-issuance, which fails the
certificate.
Nothing detects this and nothing warns you. Give every node its own
zs.nfd_record_name, or its own NFD.
Switching an existing node from the apex to a label (or back) leaves the old
record behind: ip_sync manages only the record it is configured for and never
deletes co-resident entries. Delete the stale one yourself:
zs-node nfd-dns delete --name=@ --type=a
zs-node nfd-dns delete --name=@ --type=aaaa # only if you published v6
Which account signs the DNS writes
Read this before you decide which wallet owns your NFD. It is the one choice here that is awkward to undo.
Both kinds of record this page depends on, the _acme-challenge TXT that proves
you control the name and the A/AAAA that resolves it, live in your NFD's
u.dns field. An NFD can only be updated by the account that owns it. Before
each write the node resolves the NFD's current owner and refuses to sign for
anyone else:
nfd update "yourname.algo": keystore has no key for owner ABCD…XYZ
To link an NFD to your operator record, the contract requires
it to be owned by your operator owner address, the cold wallet these docs
tell you to keep off the machine. The node holds only its hot signing key.
So a node configured as the rest of the documentation describes starts and
serves traffic normally, then fails on the first certificate renewal or the
first IP change, with that message and nothing else. Neither zs-node init nor
zs-node doctor checks for it.
Three ways out:
| Option | What you do | What it costs you |
|---|---|---|
| Give the node its own NFD | Hold a second NFD (or a segment NFD) on that node's signing address, and set zs.nfd_app_id to it explicitly in config.yaml. In this layout the explicit key is required: your operator record has no NFD linked, so the value the node would otherwise read from chain is 0, and ACME refuses to boot. One NFD per node also makes the clobbering above impossible. | One NFD per node. The contract won't let the owner link an NFD it doesn't hold, so your operator record carries none and shows as a shortened address rather than a name. |
| Transfer your NFD to the signing address | Nothing re-checks NFD ownership after registration, so moving the NFD to the node's signing address works and keeps your operator record's name linked. | Your public operator identity now sits behind a hot key: whoever takes that key can rename, repoint, or transfer it. It also doesn't extend to a second node with a different signing key, and registering a new operator record later would fail the ownership check. |
| Don't use NFD DNS | tls.mode: manual (or a TLS-terminating reverse proxy) with your own DNS and your own certificate authority. | You own renewals, the DNS record, and the base URL, by hand. See When you'd rather do it yourself. |
Whichever you choose sets which account pays for the DNS writes; see What it costs.
Set it up
Seed the DNS record
Write the A record once, before you start the node. zs-node nfd-dns uses the
same config and keystore as the daemon:
# Detect this machine's public IPv4 and write the apex A record
zs-node nfd-dns set-apex auto
# Under a label instead, matching zs.nfd_record_name
zs-node nfd-dns set-apex --name=node1 auto
# Both families, written in a single transaction
zs-node nfd-dns set-apex --ip-version=both auto
# Check what's there
zs-node nfd-dns list
The record is written with a 60-second TTL, matching what the ip_sync loop
maintains, so a seeded record and a loop-managed one agree. Full flag reference:
nfd-dns.
Turn on ACME
server:
listen: ":9090" # bind all interfaces; this port
# is what gets published on chain
tls:
mode: acme
acme:
cache_dir: "/var/lib/zs-node/tls-cache" # absolute and persistent
domains is derived from your NFD and zs.nfd_record_name, and email
defaults to operator_<id>@algo.xyz (a contact address for Let's Encrypt that
need not be deliverable).
cache_dir must be persistent, and the default probably isn't. It defaults
to ./tls-cache, relative to the process working directory, and it holds
your Let's Encrypt account key along with the issued certificates. A container
without a volume, or a systemd unit whose WorkingDirectory moves, loses it and
registers a new account on every boot until Let's Encrypt's rate limits cut you
off. Point it at an absolute path on durable storage:
/var/lib/zs-node/tls-cache under the generated systemd unit, or a path on the
/app/data volume under Docker.
To rehearse without spending Let's Encrypt quota, first point directory_url
at their staging endpoint
(https://acme-staging-v02.api.letsencrypt.org/directory). Its certificates
aren't publicly trusted, but every other part of the path is exercised.
Confirm the certificate
Issuance runs in the background. The node logs listener up scheme=https
and accepts connections before the first certificate exists, so early TLS
handshakes fail, while /healthz on the plaintext private listener reads green
the entire time, so a passing health check doesn't mean HTTPS works. Check from
off-box instead:
curl -vI https://node1.yourname.algo.xyz:9090/v1/zs/details
Give it a few minutes. The DNS-01 challenge has to travel from an on-chain
confirmation through the NFD indexer to the authoritative DNS zone before Let's
Encrypt can see it. That's why propagation_delay defaults to 30s before the
solver starts polling, and propagation_timeout to 6m. Don't shorten the
delay: a resolver that queries too early caches an NXDOMAIN for the record you
just wrote, and you get intermittent failures.
Turn on ip_sync
server:
tls:
mode: acme
ip_sync:
enabled: true
The address families default to auto-detect, so the loop publishes A and/or
AAAA for whichever families the host has a public address on, and skips the
others.
Check your base URL on chain
With ip_sync on, URL sync comes on with it. At startup the node derives
https://<hostname>:<port> from your NFD, your label, and server.listen,
compares it with your node record, and writes it if they disagree.
The port is always appended, and it comes only from server.listen. Leave
the default and you publish https://<hostname>:9090. Set listen: ":443" and
you publish https://<hostname>:443. The host half of server.listen is
discarded, so a loopback-bound node publishes an unreachable URL without
warning. Bind a public address before you enable this.
Confirm with zs-node doctor, which checks that your on-chain base URL is set
and that its port is the port the node listens on.
What ip_sync actually does
The knobs are in Configuration. This is the behavior behind them.
Detection. Each tick, the node asks https://api64.ipify.org for its public
address, once per active family, over a dialer pinned to IPv4 or to IPv6. The
pinning is how the two families are told apart, and it means there is exactly
one detection service, with no fallback. Detection also honors HTTP_PROXY and
HTTPS_PROXY, so a node whose egress runs through a proxy publishes the
proxy's address, which is usually not what you want.
The ipv4 / ipv6 tri-state. Omit them (the default) and each family is
auto-detected: present ones are published, and absent ones are logged at debug,
since a v4-only host with no public v6 is normal. Set one to false and that
family is never probed. Set one to true to assert the host has a public
address on it; a sustained detection failure then logs a WARN once, on the
healthy-to-failing transition, then debug. Setting both to false while
ip_sync is enabled is a startup error.
Writes. The loop replaces the A/AAAA entries at the one record it owns,
the apex or your zs.nfd_record_name label. It leaves everything else in
u.dns alone: other names, SRV records, TXT records, and the ACME challenge.
That guarantee holds for one writer, not two; see
One label per node.
Why ttl mirrors interval. TTL is how long resolvers may cache the
record, so it bounds how long clients keep dialing your old address after
the loop has published the new one. Matching it to the tick interval means
recovery is limited by how quickly the loop detects the new address, rather than
by how long resolvers cache the old one.
Cost. Steady state is one HTTPS probe per active family per tick and zero on-chain transactions. Only actual drift writes anything.
The base-URL side. When url.enabled is on (the default once ip_sync is
enabled), the node also keeps your node record's base URL correct. It calls
updateNodeUrl against your node box, which the contract authorizes for either
the operator owner or the node's signing key, so your hot signing key drives
it, at a flat single-fee transaction. It requires the contract un-paused and your
operator status ACTIVE; an evicted operator produces a warning every tick. The
248-byte URL ceiling is checked at startup and is fatal there, so an over-long
label fails loudly.
Set url.enabled: false when something in front of the node publishes a
different port than server.listen, such as a CDN or a reverse proxy
terminating on 443. The loop publishes the listen port verbatim, so left on it
would advertise an endpoint nobody can reach. Manage the base URL from the
operator dashboard instead.
These preconditions refuse to start the node. They don't warn and skip.
ip_syncrequirestls.modeto bemanualoracme.ip_syncrequiresalgod.networkto betestnetormainnet.url.enabledrequirestls.mode: acmespecifically, pluszs.escrow_app_id.
The last one catches people: url.enabled defaults to on, so a node using
manual certificates with ip_sync enabled won't boot until you explicitly set
url.enabled: false.
Once the node is running, the loop logs and tolerates every failure. Nothing gates serving, and the next tick retries.
What it costs
| Event | On-chain writes | Which account pays |
|---|---|---|
| Certificate issue or renewal | 2 NFD updates (write the TXT, then delete it) | The NFD's owner |
| Your public IP changes | 1 NFD update (both families fold into one) | The NFD's owner |
| Your base URL changes | 1 updateNodeUrl, flat single fee | The node's signing address |
| Steady state | None | — |
Two different accounts pay, as a consequence of Which account signs the DNS writes. The node monitors and alerts on its signing balance. If a separate account owns your NFD, that account needs its own small ALGO float, and nothing is watching it for you.
When you'd rather do it yourself
Use tls.mode: manual with your own cert_path and key_path if you already
have a certificate pipeline; rotate by replacing the files and restarting. You
run your own DNS and CA, and manage the base URL from the dashboard. If you still
want the IP loop, set url.enabled: false.
A TLS-terminating reverse proxy in front of the node works too, as a single front door for a single node, never a fan-out across instances. See The operator/node split.
The loopback model. DNS-01 proves control of a name and never needs an
inbound connection, so you can point your A record at 127.0.0.1
(zs-node nfd-dns set-apex localhost) and still hold a publicly trusted
certificate for the public hostname. DNS returns loopback to everyone, so each
user's browser reaches their own local node. Use it when each user runs a local
node behind a shared web front end.
When it doesn't come up
- The node won't start, complaining about the operator's NFD → ACME requires the operator's NFD on chain
- The node won't start with
ip_syncon and manual certificates → ip_sync with manual certs - The certificate never issues; the logs mention a keystore and an owner → keystore has no key for owner
- HTTPS fails for a while after a restart, then starts working → HTTPS fails right after start
- Clients reach a different node each time; NFD transactions every minute → Two nodes overwriting each other's DNS record