# 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. The only thing substituted during that run was the tutorial directory. ## What you need - Zig 0.16.0 and Node.js 24, both on your `PATH`. - `dig` (from bind-tools or dnsutils) and `curl`. - 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 ```sh 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 10 and find no admin interface there. ## 2. Build nxdns ```sh 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 ```sh 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](../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. Seeding happens inside `run`, so `run` prints the problem and exits 1; `nxdns check` and `nxdns import` reject the same file with exit code 2. ## 4. Check the configuration before starting ```sh 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 https://cloudflare-dns.com/dns-query OK: no problems found ``` `check` parses the file, validates it, and contacts each upstream to confirm it answers. It exits 0 when there is nothing to fix and 2 when there is. It does not start any listener, so you can run it as often as you like. ## 5. Start the server In your first terminal: ```sh zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon ``` ``` info(migrations): config.db migrated from schema version 0 to 2 info(config_bootstrap): seeded the database from '/home/you/nxdns-tutorial/config.zon' 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): nxdns 0.1.0-dev 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 ``` The data directory did not exist; nxdns created it at mode 0700 along with `config.db` and `querylog.db`. The line that matters is `seeded the database from`: the configuration file was read into the database this once. From now on the database is the truth and the file is ignored on every later start, which you will see for yourself in step 11. Configuration changes go through the API, the web interface, or `nxdns import`. Leave this terminal running and switch to the second one. ## 6. Resolve a name ```sh 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 5 said `0 of 0 sources loaded`. ## 7. Add a blocklist source ```sh 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"}' ``` ```json {"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](../how-to/set-up-admin-authentication.md). Note the `"id":1` in the response. You need it in the next step. ## 8. 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. Ask which groups exist: ```sh curl -s http://127.0.0.1:8080/api/groups ``` ```json {"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: ```sh curl -s -X PUT http://127.0.0.1:8080/api/groups/1/sources \ -H 'Content-Type: application/json' \ -d '{"source_ids":[1]}' ``` ```json {"source_ids":[1]} ``` ## 9. Download the list Adding a source registers it; it does not fetch it. Ask for a refresh: ```sh curl -s -X POST http://127.0.0.1:8080/api/blocklists/update ``` ```json {"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1785683777,"last_success":1785683777,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":99277,"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 5: 1 of 1 sources loaded, 3088703 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 — 99277 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. ## 10. Watch a domain get blocked ```sh 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: ```sh 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. ## 11. Open the web interface Visit 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.) ## 12. 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 5 and read the first log line: ``` info(config_bootstrap): configuration file ignored; the database is already configured info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 3088703 bytes ``` The configuration file was not even opened, and the blocklist came off disk rather than the network. The blocklist source, the group it is attached to, and the query log all survived the restart because they live in `~/nxdns-tutorial/data`. Press Ctrl-C again to stop this second process. Nothing is listening on 15353 or 8080 now, and nothing of nxdns is running. ## 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: ```sh rm -rf ~/nxdns-tutorial ``` You also saw the two things that surprise people most: the configuration file seeds the database once and is ignored afterwards, and a blocklist source does nothing until a group uses it. ## Where to go next - [how-to/install-with-systemd.md](../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](../how-to/set-up-admin-authentication.md) — put a password on the web interface before it leaves localhost. - [reference/configuration.md](../reference/configuration.md) — every field you did not set. - [explanation/configuration-model.md](../explanation/configuration-model.md) — why the file seeds and the database rules.