13 KiB
Your first nxdns
This page takes you from a checkout of the repository to a running nxdns that answers DNS queries, blocks domains from a real blocklist, and shows you the web interface. It runs on unprivileged ports in a scratch directory, so nothing on your machine changes and nothing needs root. At the end you stop the server and delete the directory.
Follow the steps in order. Each one says what it did.
Every command below was executed on x86_64 Linux with Zig 0.16.0, Node.js
24.14.1, dig 9.20.26 and curl 8.21.0. Steps 4 to 11, 13 and 14 were re-run end
to end for this revision, and the transcripts are that run's output with the
tutorial directory substituted. Two things were not re-run: the browser page in
step 12 — its endpoints were exercised, the page itself was not opened — and the
two build commands in steps 1 and 2, which had already produced the binary under
test. The ZON block at the end of step 14 was checked with nxdns check --config rather than started.
What you need
- Zig 0.16.0 and Node.js 24, both on your
PATH. dig(from bind-tools or dnsutils) andcurl.- Working internet access: nxdns queries an upstream resolver over HTTPS and downloads a 3 MB blocklist.
- Two terminals. The server runs in the first one; you type into the second.
Build commands run from the repository root. The server and the queries use
~/nxdns-tutorial for everything they write.
1. Build the web interface
cd web && npm ci && npm run build && cd ..
This produces web/dist. Do not skip it. A plain zig build embeds
web/dist-placeholder, a one-page status stub, and you would reach step 12 and
find no admin interface there.
2. Build nxdns
zig build -Dweb-dist=web/dist
The binary is zig-out/bin/nxdns, with web/dist embedded in it. SQLite and
mbedTLS are vendored and built by this command, so the first build takes a
while; later builds are cached.
3. Write a configuration file
mkdir -p ~/nxdns-tutorial
cat > ~/nxdns-tutorial/config.zon <<'EOF'
.{
.dns = .{ .bind_ipv4 = "127.0.0.1", .bind_ipv6 = "::1", .port = 15353 },
.web = .{ .bind = "127.0.0.1", .port = 8080 },
.groups = .{
.{ .name = "default" },
},
.upstreams = .{
.{ .url = "https://cloudflare-dns.com/dns-query" },
},
}
EOF
Four things are set, and the rest of nxdns keeps its defaults.
DNS is on port 15353 instead of 53, and both listeners are bound to localhost.
Port 53 is privileged: binding it needs root or CAP_NET_BIND_SERVICE, which is
an install decision, not a first-run decision. A real install is covered in
how-to/install-with-systemd.md.
The default group and one enabled upstream are the two things nxdns will not
start without. Every client that nxdns has never seen is assigned to default,
and with no usable upstream there is nowhere to send a query it cannot answer
itself, so a configuration missing either one is rejected. Whichever command
reads the file says the same thing and stops the same way: run, nxdns check
and nxdns import all print the problem and exit 2.
This tutorial loads the file into the database once and then runs nxdns against the database, which is what the packaged systemd unit does. There is a second way to run nxdns, where the file itself stays the configuration; step 14 shows what changes.
4. Check the configuration before starting
zig-out/bin/nxdns check --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon
checking configuration file /home/you/nxdns-tutorial/config.zon
OK upstreams[0] https://cloudflare-dns.com
OK: no problems found
check parses the file, validates it, and contacts each upstream to confirm it
answers. It exits 2 when it found something that has to be fixed and 0
otherwise. It writes nothing and starts no listener, so you can run it as often
as you like.
5. Load it into the database
zig-out/bin/nxdns import ~/nxdns-tutorial/config.zon --data-dir ~/nxdns-tutorial/data
info(migrations): config.db migrated from schema version 0 to 2
imported /home/you/nxdns-tutorial/config.zon
The data directory did not exist; nxdns created it at mode 0700 along with
config.db. From here the database holds the configuration, and you will change
it through the API rather than by editing the file again.
6. Start the server
In your first terminal:
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data
info(querylog_schema): created querylog database '/home/you/nxdns-tutorial/data/querylog.db'
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 199 bytes
info(nxdns): authority: database
info(nxdns): nxdns <version> serving on udp [::1]:15353 udp 127.0.0.1:15353 tcp [::1]:15353 tcp 127.0.0.1:15353; 1 upstream(s); blocklist generation 1
info(web_server): web interface listening on 127.0.0.1:8080
No --config here, and that is the point: authority: database says the
database is the configuration and no file was opened at all. The file you wrote
in step 3 has done its job.
Leave this terminal running and switch to the second one.
7. Resolve a name
dig @127.0.0.1 -p 15353 example.com A +noall +answer
example.com. 90 IN A 172.66.147.243
example.com. 90 IN A 104.20.23.154
nxdns had no answer cached, so it forwarded the query to
https://cloudflare-dns.com/dns-query over HTTPS and returned what came back.
You now have a working resolver. It blocks nothing yet: the log line in step 6
said 0 of 0 sources loaded.
8. Add a blocklist source
curl -s -X POST http://127.0.0.1:8080/api/blocklists \
-H 'Content-Type: application/json' \
-d '{"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","name":"StevenBlack hosts"}'
{"id":1,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","name":"StevenBlack hosts","enabled":true,"is_suggested":false}
The API is unauthenticated here because the configuration set no web.password.
That is fine for a localhost tutorial and wrong for anything else; see
how-to/set-up-admin-authentication.md.
Note the "id":1 in the response. You need it in the next step.
9. Attach the source to the default group
A blocklist source belongs to the installation. Which groups use it is a separate decision, which is what lets one group get a strict list and another get none. A source that is attached to no group is downloaded and then filters nothing.
You are in that state right now, between the previous step and this one, and it
is legal rather than wrong — creating a source and attaching it afterwards is
the normal order, which is why the API accepted it without complaint. On a
stopped server nxdns check names such a source in a WARN line and still
exits 0, so a list that silently blocks nothing is something you can find out
about later. Attaching it now is what makes it take effect.
Ask which groups exist:
curl -s http://127.0.0.1:8080/api/groups
{"groups":[{"id":1,"name":"default","safe_search":false}]}
The default group is id 1. Give it the source you just created — the request
body is the complete set of sources for that group, so sending [1] replaces
whatever was there:
curl -s -X PUT http://127.0.0.1:8080/api/groups/1/sources \
-H 'Content-Type: application/json' \
-d '{"source_ids":[1]}'
{"source_ids":[1]}
10. Download the list
Adding a source registers it; it does not fetch it. Ask for a refresh:
curl -s -X POST http://127.0.0.1:8080/api/blocklists/update
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1786473715,"last_success":1786473715,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":99559,"wildcards":0,"skipped_regex":0}]}
The download is about 3 MB and takes a few seconds. Watch the first terminal until this appears:
info(blocklist_manager): blocklist snapshot generation 6: 1 of 1 sources loaded, 3096006 bytes
1 of 1 sources loaded is the line to wait for. nxdns builds each blocklist
snapshot in full and swaps it in atomically, so queries keep being answered from
the previous snapshot the whole time the new one is being built. After this, the
domain count in the JSON above — 99559 on the day this was run — is live.
From here on, the list is on disk under ~/nxdns-tutorial/data/blocklists.
Restarting nxdns does not re-download it.
11. Watch a domain get blocked
dig @127.0.0.1 -p 15353 doubleclick.net A +noall +answer
doubleclick.net. 5 IN A 0.0.0.0
0.0.0.0 with a TTL of 5 is a block, not an answer. The default block response
is the zero address and the default block TTL is 5 seconds, so a client that
caches the answer forgets it quickly after you unblock something.
A name that is not on the list still resolves normally:
dig @127.0.0.1 -p 15353 wikipedia.org A +noall +answer
wikipedia.org. 17 IN A 185.15.58.224
One thing to know before you try other names: a blocklist entry blocks exactly
the name it names. doubleclick.net on the list does not block
ads.doubleclick.net; that name is blocked because the list happens to contain
it too. Blocklist entries do not walk up the parent chain — only rules you write
yourself can, with a wildcard pattern such as *.doubleclick.net. So when you
pick a domain to test, pick one that is literally in the file.
www.google-analytics.com is another that is.
12. Open the web interface
Visit http://127.0.0.1:8080 in a browser. This is the single-page application you built in step 1, served out of the binary. The dashboard shows query and block counts, and the Blocklists page shows the source you added with its domain count. (The endpoints behind those two pages were checked while writing this; the browser page itself was not opened on the verification host.)
13. Stop it
In the first terminal, press Ctrl-C.
info(nxdns): shutting down
nxdns catches SIGINT and SIGTERM, stops serving and exits 0.
Start it again with the same command as in step 6 and read the first log lines:
info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 3096006 bytes
info(nxdns): authority: database
The blocklist came off disk rather than the network. The source you added, the
group it is attached to, and the query log all survived the restart because they
live in ~/nxdns-tutorial/data, which is what authority: database means in
practice.
Press Ctrl-C again to stop this second process. Nothing is listening on 15353 or 8080 now, and nothing of nxdns is running.
14. See what the other mode does
You have been running in database mode. The alternative is to hand the same file
back as the configuration, which is what --config means:
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon
reconciled '/home/you/nxdns-tutorial/config.zon': sources +0 ~0 -1; group_sources +0 ~0 -1;
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 199 bytes
info(nxdns): authority: file (/home/you/nxdns-tutorial/config.zon)
Read that first line. The blocklist source is gone. That is not a bug — it is the whole contract. The file you wrote in step 3 never mentioned a blocklist source, and in file mode the file is the complete statement of what the configuration is, so anything the database holds that the file does not name is removed at every start. The reconcile said so in one line before doing it.
The compiled list is still on disk and the query log is untouched; what changed is the configuration, and it now matches the file exactly.
Neither mode is the "advanced" one. Database mode suits a box someone administers through the web interface. File mode suits a file kept in git and deployed by a tool, where the deployed file being what is running matters more than clicking. To have kept the blocklist here, you would put it in the file:
.blocklist_sources = .{
.{ .url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", .name = "StevenBlack hosts" },
},
.group_sources = .{
.{ .group = "default", .source_url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" },
},
Ctrl-C to stop it.
What you have now
A resolver that answers real queries, a real blocklist of about 99000 domains attached to the default group, a query log, and a web interface — all inside one directory you can delete:
rm -rf ~/nxdns-tutorial
You also saw the three things that surprise people most: a blocklist source does nothing until a group uses it, which authority a start runs under is printed rather than guessed, and in file mode anything the file does not name is removed at the next start.
Where to go next
- how-to/install-with-systemd.md — the same thing as a real service on port 53, including a Raspberry Pi 5.
- how-to/set-up-admin-authentication.md — put a password on the web interface before it leaves localhost.
- reference/configuration.md — every field you did not set.
- explanation/configuration-model.md — why there are two authority modes and what each one is for.