# Install nxdns with systemd Installs nxdns as a system service on a Linux host with systemd, including a Raspberry Pi 5. At the end the service answers DNS on port 53 and starts on boot. For what each flag does, see [the CLI reference](../reference/cli.md); for what each configuration field means, see [the configuration reference](../reference/configuration.md). > Verification: the build steps and `systemd-analyze verify` were run on the > machine that wrote this page. `nxdns check` and `nxdns run` were run there > too, but against a scratch `--data-dir` and `--config` on an unprivileged > port, because that machine is not a deploy target and has no `/etc/nxdns`, > no `/var/lib/nxdns` and no root. The steps that need root on a target host — > `install`, `systemd-sysusers`, `systemctl` — were not run; they are marked > where they appear. ## 1. Build the binary Requires Zig 0.16.0 and Node.js. From the repository root: ```sh (cd web && npm ci && npm run build) zig build cross -Dweb-dist=web/dist -Doptimize=ReleaseSafe ``` The first command builds the admin interface into `web/dist`; the second embeds that directory in the binary. Build the interface every time, before the binary: a stale `web/dist` ships an admin UI that does not match the API it talks to. Two static musl binaries come out, one per deploy target: - `zig-out/cross/x86_64-linux-musl/nxdns` - `zig-out/cross/aarch64-linux-musl/nxdns` Both are statically linked and need nothing installed on the target host. ## 2. Copy the files to the target ```sh scp zig-out/cross/x86_64-linux-musl/nxdns target:/tmp/nxdns scp deploy/systemd/nxdns.service deploy/systemd/sysusers.conf target:/tmp/ ``` For a Raspberry Pi 5, copy `zig-out/cross/aarch64-linux-musl/nxdns` instead — see [Raspberry Pi 5](#raspberry-pi-5) below. > Not verified on this host: `target` is a placeholder for your server's > hostname, and the machine that wrote this page has no second host to copy to. > What was verified is that both source paths exist after step 1 and that the > aarch64 file is a statically linked aarch64 ELF executable. Before copying, you can confirm the unit file parses: ```sh systemd-analyze verify deploy/systemd/nxdns.service ``` Off the target host this prints one complaint and exits 1: ``` nxdns.service: Command /usr/local/bin/nxdns is not executable: No such file or directory ``` That is the ExecStart path check finding no binary yet. Any other message is a real problem with the unit. On the target, after step 3, the same command should print nothing. ## 3. Install the binary, the user and the unit Run as root on the target: ```sh 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 mkdir -p -m 0755 /etc/nxdns ``` > Not verified on this host: these commands need root on a target machine. The > files they install were read at HEAD and the unit was checked with > `systemd-analyze verify`. The service user is a static one, not `DynamicUser`: a TLS key for the DoH or DoT listener has to be chown-able to a uid that survives a restart. Do not create `/var/lib/nxdns` or `/var/log/nxdns` by hand. The unit's `StateDirectory` and `LogsDirectory` settings make systemd create them on first start, `/var/lib/nxdns` at mode 0700 owned by `nxdns`. `/etc/nxdns` is the one directory the `mkdir` above is for. The unit's `ConfigurationDirectory=nxdns` also creates it, but not until the first start in step 5, and step 4 has to write a file into it before then. systemd does not mind finding the directory already there; it adjusts the mode and ownership to what the unit asks for. ## 4. Write the seed configuration nxdns starts from an empty database only if a configuration file tells it what to forward to. Write `/etc/nxdns/config.zon`. The smallest file 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" }, } ``` That file holds a password in plain text, so restrict it as soon as you have written it: ```sh chown root:nxdns /etc/nxdns/config.zon chmod 0640 /etc/nxdns/config.zon ``` Root's umask is 022 on most distributions, so a freshly written `/etc/nxdns/config.zon` is mode 0644 and every account on the host can read the password out of it. The unit's `UMask=0077` does not help here: it applies to files the service creates once it is running, and never re-chmods a file that was written before the first start. 0640 with group `nxdns` rather than 0600: `/etc/nxdns` is a `ConfigurationDirectory`, which systemd leaves owned by root, and the service runs as `nxdns` and has to read this file on the first start. A root-owned 0600 file would be unreadable to it. Do not expect `nxdns check` to catch a permissive mode here. Its only permission warning is for a TLS private key (`WARN doh_server.key_path: ... is mode 644; a TLS key must be readable by its owner only`, from `checkTlsFiles` in `src/cli.zig`); it never stats the configuration file. A mode 0644 `config.zon` passes `check` in silence, so the `chmod` above is yours to remember. Check it before you start the service: ```sh nxdns check --config /etc/nxdns/config.zon ``` A good file prints the source it checked, one `OK` line per upstream, and `OK: no problems found`: ``` checking configuration file /etc/nxdns/config.zon OK upstreams[0] https://cloudflare-dns.com OK: no problems found ``` The upstream probe sends a real query, so this needs working DNS on the host at the time you run it. Exit 2 means `check` found something to fix and printed every problem it found, not only the first. The file seeds the database once. From the second start onwards it is ignored and the database is the configuration; see [the configuration model](../explanation/configuration-model.md) and [Upgrade nxdns](upgrade.md) for how to change settings after that. Once the seed has been consumed — after step 6 confirms you can log in — the plaintext in it is dead weight that only carries risk. The seed's `web.password` is hashed into `web.password_hash` at import time and the plaintext is never stored; `nxdns export` writes `.password = ""` back out alongside the hash. Nothing downstream ever reads the plaintext again, so delete the file: ```sh rm /etc/nxdns/config.zon ``` Keep it only if you want the seed as a record of the intended starting configuration, and if you keep it, leave it at 0640 root:nxdns. Note that a kept seed is not a backup — `nxdns export` is (see [Back up and restore](back-up-and-restore.md)), and the export carries the password hash rather than the password. > Verified on this host, with a scratch `--config` and `--data-dir` in place of > `/etc/nxdns` and `/var/lib/nxdns`: a seed written under umask 022 came out > 0644, `nxdns check --config` on it printed `OK: no problems found` with no > mode warning, and after `nxdns import` of that seed an `nxdns export` wrote > `.password = ""` next to a populated `.password_hash = > "$argon2id$v=19$..."`. The `chown`, `chmod` and `rm` lines above are the > ordinary root-owned-file operations and were not run against a real > `/etc/nxdns`, which this host does not have. ## 5. Start it ```sh systemctl enable --now nxdns journalctl -u nxdns -f ``` > Not verified on this host: needs root and an installed unit. A healthy start logs a line naming every socket it bound: ``` info(nxdns): nxdns 0.1.0-dev serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1 ``` nxdns writes to stderr and systemd captures that into the journal; logging needs no further configuration. Port 53 is privileged, and the unit grants `CAP_NET_BIND_SERVICE` through `AmbientCapabilities`. If the start fails, read [Troubleshoot nxdns](troubleshoot.md). The two common first-install failures are a port 53 already held by `systemd-resolved` and a seed file that does not parse. ## 6. Confirm it answers From another machine on the LAN: ```sh dig @ example.com A +short ``` The admin interface is on port 8080 by default; log in with the password from the seed file. `http://:8080/api/health` reports upstream availability and disk state without a login. > Not verified on this host as written: `` is a placeholder, and a > LAN client to run it from is a second machine this host does not have. What > was verified is the same two checks against a local nxdns started from a > scratch data directory on an unprivileged port — `dig @127.0.0.1 -p 15353 > example.com A +short` returned the A records, and `curl` against the web > port returned 200. Only the address and the port differ from the lines > above. ## Raspberry Pi 5 The Pi 5 is aarch64. Nothing about the procedure changes except which binary you copy — the cross build needs no toolchain on the Pi and no toolchain beyond Zig on the build machine: ```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/ ``` Then follow steps 3 to 6 on the Pi. The build was run on the machine that wrote this page and `zig-out/cross/aarch64-linux-musl/nxdns` is a statically linked aarch64 ELF executable. > Not verified on this host: the two `scp` lines. `pi` is a placeholder for > your Pi's hostname, and this page was written on an x86_64 machine with no Pi > attached. The build steps above it were run; the copy was not. > Not verified on this host: the aarch64 binary was not executed. This host is > x86_64 and has no `qemu-aarch64` to run it under. Running it needs a > Raspberry Pi 5 or another aarch64 machine.