# 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](config-reference.md); for the HTTP API, [api.md](api.md). ## Install: systemd nxdns ships as one static musl binary. Build it (see [Building](#building-the-binary)) or take it from CI, then: ```sh # 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: ```zon .{ .groups = .{ .{ .name = "default" } }, .upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } }, .web = .{ .password = "choose-a-real-password" }, } ``` Then: ```sh 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](#port-53-is-taken-systemd-resolved). ### Building the binary Requires Zig 0.16.0 and Node.js 24 (for the web UI). From the repository root: ```sh (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: ```sh (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: ```sh (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): ```sh 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`: ```sh 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 `, then `docker tag nxdns //nxdns:`, then `docker push //nxdns:`. 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: ```sh 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: ```sh 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: ```sh 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](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: ```sh 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: ```sh 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: `.list` (exact domains) and `.wild` (wildcards). `.raw.tmp` / `.list.tmp` / `.wild.tmp` files are transient refresh state. | ## CLI reference ``` nxdns [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](#check-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 `/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: ```sh 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.