Files
nxdns/docs/operator.md
T

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/nxdns
  • zig-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:

  1. Which source to check (see source selection).
  2. Full validation — the same rules import enforces.
  3. For each enabled DoH/DoT listener: cert and key are readable (FAIL if not), key permissions are owner-only (WARN if not).
  4. A live probe: one real A query for example.com through 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, so check needs 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 FILE given explicitly: check that file, nothing else.
  • Otherwise, if <data-dir>/config.db exists: 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 default group or no enabled upstream. On a fresh install this means config.zon is 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_ipv6 is not an IP address, or a rate limit is zero (possible only in a hand-edited database; import refuses both).
  • A DoH/DoT listener is enabled but its certificate or key is unreadable or unparseable at boot.
  • import into 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.