15 KiB
Operating nxdns
How to install, configure, back up, upgrade and troubleshoot an nxdns server. For the meaning of every configuration field, see config-reference.md; for the HTTP API, api.md.
Install: systemd
nxdns ships as one static musl binary. Build it (see Building) or take it from CI, then:
# 1. The binary.
install -m 0755 nxdns /usr/local/bin/nxdns
# 2. The service user. Static, not DynamicUser: the TLS key for DoH/DoT
# must be chown-able to a stable uid.
install -m 0644 deploy/systemd/sysusers.conf /usr/lib/sysusers.d/nxdns.conf
systemd-sysusers
# 3. The unit.
install -m 0644 deploy/systemd/nxdns.service /etc/systemd/system/nxdns.service
systemctl daemon-reload
Do not create directories by hand. The unit's StateDirectory=nxdns,
LogsDirectory=nxdns and ConfigurationDirectory=nxdns make systemd create
/var/lib/nxdns (mode 0700, owned by nxdns), /var/log/nxdns and
/etc/nxdns on first start.
Write a seed configuration to /etc/nxdns/config.zon. The minimum that
starts is one group named default and one enabled upstream:
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
.web = .{ .password = "choose-a-real-password" },
}
Then:
systemctl enable --now nxdns
journalctl -u nxdns -f
nxdns logs to stderr by default and systemd captures that into the journal;
nothing else needs configuring for logs. Port 53 needs
CAP_NET_BIND_SERVICE, which the unit grants via AmbientCapabilities.
If port 53 is already taken, see Port 53 conflicts.
Building the binary
Requires Zig 0.16.0 and Node.js 24 (for the web UI). From the repository root:
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
This produces static binaries for both deploy targets:
zig-out/cross/x86_64-linux-musl/nxdnszig-out/cross/aarch64-linux-musl/nxdns
Raspberry Pi 5 recipe
The Pi 5 is aarch64. Build on any machine (the cross build needs no toolchain beyond Zig itself), copy the binary over, then follow the systemd steps above on the Pi:
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
scp zig-out/cross/aarch64-linux-musl/nxdns pi:/tmp/nxdns
scp deploy/systemd/nxdns.service deploy/systemd/sysusers.conf pi:/tmp/
# on the Pi, as root:
install -m 0755 /tmp/nxdns /usr/local/bin/nxdns
install -m 0644 /tmp/sysusers.conf /usr/lib/sysusers.d/nxdns.conf
systemd-sysusers
install -m 0644 /tmp/nxdns.service /etc/systemd/system/nxdns.service
systemctl daemon-reload
# write /etc/nxdns/config.zon, then:
systemctl enable --now nxdns
The binary is statically linked against musl; it has no runtime dependencies on the Pi.
Install: Docker
The image is built from a binary you compile first; the Dockerfile only assembles the filesystem. From the repository root:
(cd web && npm ci && npm run build)
zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe
docker build -t nxdns -f deploy/docker/Dockerfile .
or, with compose (which runs the same build with the repository root as context):
cd deploy/docker
docker compose build
The Dockerfile maps buildx's TARGETARCH onto the cross-target directory,
so docker buildx build --platform linux/arm64 produces the aarch64 image
from the same zig-out/cross tree.
Before the first docker compose up, create the seed configuration the
compose file bind-mounts read-only at /etc/nxdns:
cd deploy/docker
mkdir -p etc-nxdns
$EDITOR etc-nxdns/config.zon # the same minimal seed as the systemd path
The seed must be readable by uid 65532, the fixed container user; the bind
mount is read-only, so the container cannot adjust permissions itself.
World-readable (0644) is fine when the seed carries no secret; if it holds
web.password or web.password_hash (a restored export), restrict it instead:
chown 65532:65532 etc-nxdns/config.zon && chmod 0600 etc-nxdns/config.zon.
Without a valid seed — at least a default group and one enabled
upstream — the container exits with code 2, because a fresh volume holds an
empty database and an empty database has no upstream to forward to.
The compose file publishes 53/udp, 53/tcp and 8080, keeps the data in a
named volume mounted at /var/lib/nxdns, and sets the per-network-namespace
sysctl net.ipv4.ip_unprivileged_port_start=0 so the nonroot user
(uid 65532) can bind port 53. Uncomment the 443/853 port mappings when you
enable the DoH or DoT listener.
Do not point the host's /etc/resolv.conf at the nxdns container. The
container resolves its upstream DoH/DoT hostnames through the host's DNS
configuration; pointing that at nxdns itself makes the container's own
lookups depend on the service they are trying to start.
Publishing the image to a private registry
There is deliberately no registry push in CI — credentials and registry
choice are an infrastructure decision, not this repository's. To publish
manually, log in to your registry, tag the local image with the registry's
name, and push: docker login <registry>, then
docker tag nxdns <registry>/<owner>/nxdns:<tag>, then
docker push <registry>/<owner>/nxdns:<tag>. The same works for a
self-hosted Gitea registry such as git.mial.net.
First boot and configuration semantics
The ZON file at /etc/nxdns/config.zon (or --config) seeds the database
exactly once:
- No file: normal steady state; the database is used as it is.
- File present, database empty: the file is imported. A file that is unreadable, unparseable or invalid is an error (exit 2, every problem printed) — nxdns never falls back to silent defaults over a file you wrote.
- File present, database already configured: the file is ignored. The database is the truth from the first successful seed onward.
After the first boot, editing config.zon changes nothing. Change the
configuration through the web UI, the REST API, or the export→edit→import
cycle:
nxdns export --out config-backup.zon
$EDITOR config-backup.zon
systemctl stop nxdns
nxdns import config-backup.zon --force
systemctl start nxdns
import without --force refuses a database that already has content
(exit 2), so a plain import can never clobber a configured server by
accident.
Authentication setup
Set web.password in the seed file (or in a file you import). At import
time it is hashed with argon2id into web.password_hash and discarded; the
plaintext is never stored anywhere. nxdns export always writes
.password = "" and carries the hash instead, so an exported file
re-imports without knowing the password. Setting both password and
password_hash in one file is an error (exit 2). To change the password,
export, set .password to the new value, clear .password_hash to "",
and import with --force.
TLS for the DoH/DoT listeners
Both listeners are disabled by default. To enable one, set
doh_server.enabled / dot_server.enabled and point cert_path and
key_path at a PEM certificate chain and key, conventionally under
/etc/nxdns. Ownership depends on how you deploy.
Under systemd, the service runs as the nxdns user:
chown nxdns:nxdns /etc/nxdns/cert.pem /etc/nxdns/key.pem
chmod 0644 /etc/nxdns/cert.pem
chmod 0600 /etc/nxdns/key.pem
Under Docker, the container runs as uid 65532 (fixed in the image) and
/etc/nxdns is a read-only bind mount, so the container cannot fix
permissions itself — the host-side files must already be readable by that
uid. It has no name on the host or in the scratch image, so chown it
numerically:
cd deploy/docker
chown 65532:65532 etc-nxdns/cert.pem etc-nxdns/key.pem
chmod 0644 etc-nxdns/cert.pem
chmod 0600 etc-nxdns/key.pem
The key must be readable by the user nxdns runs as and only by its owner:
nxdns check prints a WARN for a key with any group or other permission
bits, and a FAIL (exit 2) for a cert or key the user cannot read. A
certificate that fails to load at boot while its listener is enabled exits 2.
Renewals need no restart. A watcher polls both files every 30 seconds and
swaps the new pair in atomically; in-flight connections finish on the old
certificate. To pick up a renewal immediately — for example from a certbot
deploy hook — call POST /api/certs/reload (session-authenticated; see
api.md). A reload that fails to parse leaves the old certificate
serving and reports the error.
Backup, restore and upgrades
Backup is one command against a stopped or running server:
nxdns export --out /some/backup/nxdns-config.zon
The write is atomic (temp file + rename) and mode 0600, because the file
carries web.password_hash — treat backups as secrets. Without --out the
export goes to stdout, where file permissions are your redirect's problem.
The query log is deliberately not part of the backup; it is expendable
history.
Restore onto a fresh data directory or over an existing one:
nxdns import /some/backup/nxdns-config.zon --force
Upgrades: install the new binary, restart the service. Schema
migrations run automatically at startup (and before check, export and
import), so a database one schema version behind is upgraded in place.
There is no downgrade path; take an export before upgrading.
Data directory layout
Everything lives under the data directory (default /var/lib/nxdns,
override with --data-dir), mode 0700:
| Path | What it is |
|---|---|
config.db (+ -wal, -shm) |
The configuration database — the single source of truth, including web.password_hash. Mode 0600. Back it up via nxdns export. |
querylog.db (+ -wal, -shm) |
The query log. Mode 0600 — it records every domain every client asked for. Expendable: if it is missing or unusable it is recreated empty. |
blocklists/ |
Compiled blocklist snapshots, two files per source: <id>.list (exact domains) and <id>.wild (wildcards). .raw.tmp / .list.tmp / .wild.tmp files are transient refresh state. |
CLI reference
nxdns <command> [options]
Flags take both spellings: --flag value and --flag=value.
run
Serves DNS until SIGINT or SIGTERM.
| Flag | Meaning |
|---|---|
--data-dir DIR |
Data directory (default /var/lib/nxdns). Created at 0700 if missing. |
--config FILE |
Seed configuration file (default /etc/nxdns/config.zon). Read only when the database is empty. |
--web-dev DIR |
Serve the web interface from DIR instead of the embedded assets, with no cache headers. Development only. |
check
Validates the configuration and probes the upstreams. Exit 0 when clean, 2 when it found problems, and it always reports every problem, not just the first. What it checks, in order:
- Which source to check (see source selection).
- Full validation — the same rules
importenforces. - For each enabled DoH/DoT listener: cert and key are readable (FAIL if not), key permissions are owner-only (WARN if not).
- A live probe: one real A query for
example.comthrough every enabled upstream, using the same failover machinery the server uses. A FAIL line names the upstream and the concrete cause. This probe leaves the machine, socheckneeds network access to pass.
| Flag | Meaning |
|---|---|
--data-dir DIR |
Data directory to look for config.db in. |
--config FILE |
Check this file instead of the database. |
check source selection
--config FILEgiven explicitly: check that file, nothing else.- Otherwise, if
<data-dir>/config.dbexists: check the database — the right default, since the database is the truth on a configured server. - Otherwise, if the default config file exists: check it.
- Otherwise: "nothing to check", exit 2.
The first line of output always names which source was checked.
export
Writes the configuration as ZON to stdout, or atomically at mode 0600 to
--out FILE. --out paths are relative to the shell's working directory.
| Flag | Meaning |
|---|---|
--data-dir DIR |
Data directory holding config.db. |
--out FILE |
Write to FILE instead of stdout. |
import FILE
Validates FILE and replaces the configuration with it. Prints every
validation problem on failure. Refuses a non-empty database without
--force.
| Flag | Meaning |
|---|---|
--data-dir DIR |
Data directory holding config.db (created if missing). |
--force |
Replace a database that already has content. |
version
Prints the nxdns version, git commit and Zig version.
help
Prints usage. --help and -h do the same.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success. |
| 1 | Runtime failure — I/O, database, out of memory. |
| 2 | A configuration problem the operator can fix, or a check that found one. |
| 64 | Usage error — unknown command or flag, missing argument. |
Troubleshooting
The service exits with code 2
Exit 2 always means a configuration you can fix; nxdns run prints the
cause and suggests nxdns check, which shows the full list. The usual
causes:
- Empty database and no seed file, or a seed file with no
defaultgroup or no enabled upstream. On a fresh install this meansconfig.zonis missing, in the wrong place, or invalid. - A seed or imported file that fails validation — every problem is printed with its field name.
dns.bind_ipv4/dns.bind_ipv6is not an IP address, or a rate limit is zero (possible only in a hand-edited database;importrefuses both).- A DoH/DoT listener is enabled but its certificate or key is unreadable or unparseable at boot.
importinto a non-empty database without--force.
Port 53 is taken (systemd-resolved)
On most systemd distributions, systemd-resolved owns a stub listener on
127.0.0.53:53, and on some setups binds 0.0.0.0:53. Turn the stub off
and keep resolved for the host's own lookups:
mkdir -p /etc/systemd/resolved.conf.d
printf '[Resolve]\nDNSStubListener=no\n' > /etc/systemd/resolved.conf.d/nxdns.conf
systemctl restart systemd-resolved
If /etc/resolv.conf is a symlink to the stub
(/run/systemd/resolve/stub-resolv.conf), repoint it at
/run/systemd/resolve/resolv.conf so the host still resolves. Do not point
the host running nxdns at nxdns itself if that host is where nxdns resolves
its upstream DoH/DoT hostnames — that is a bootstrap cycle.
Disk is filling up
The disk monitor samples free space and database sizes once a minute and
classifies the state against disk.warn_free_mb and disk.min_free_mb.
Below the warn threshold it logs the transition; below min_free_mb it
gates every non-essential write: the query logger holds its batches, the
client tracker stops persisting, and the blocklist scheduler skips its
refresh passes. DNS keeps answering throughout — resolution never degrades
because the disk is full. The state and the size gauges are visible on
/metrics and in the web UI. Recover space (lower
logging.retention_days, or delete querylog.db with the service
stopped) and writes resume on the next sample.
Blocklists are not filtering
Serving starts even when no blocklist snapshot loads — a household loses
more from DNS that refuses to start than from a window of unfiltered
answers. The startup journal line says either blocklist generation N or
unfiltered (no blocklist snapshot). If it says unfiltered, check the
journal for the download or compile warning that preceded it.