21 KiB
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.
The normal path is to download a released tarball, verify it, and install what is inside it. Building from source is still supported and is the last section of this page.
For what each flag does, see the CLI reference; for what each configuration field means, see the configuration reference.
Verification:
systemd-analyze verifywas run on the machine that wrote this page.nxdns check,nxdns import,nxdns exportandnxdns runwere run there too, but against a scratch--data-dirand--configon an unprivileged port, because that machine is not a deploy target and has no/etc/nxdns, no/var/lib/nxdnsand no root. The steps that need root on a target host —install,systemd-sysusers,systemctl— were not run; they are marked where they appear.The download in step 1 could not be run at all: this repository has no tags and no published release, so every release URL on this page is a 404 today. Its commands are the ones Verify a release covers in full, and the URL shapes — including the
releases/latestredirect the version is read from — were probed there againstgitea.com, a public instance running the same Gitea series.The
zig build distandzig build verify-distblocks in the last section were run here, both to completion and both exiting 0; that section carries the detail.
1. Download and verify
Two static musl tarballs are published per release, one per architecture. Pick
x86_64-linux-musl for a normal PC or server and aarch64-linux-musl for a
Raspberry Pi 5.
BASE=https://git.mial.net/mokhtar/nxdns
VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
sed 's#.*/releases/tag/v##')
mkdir -p ~/nxdns-release && cd ~/nxdns-release
curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-x86_64-linux-musl.tar.gz"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt.asc"
The first line asks the server which release is current instead of hardcoding a
number that goes stale one release later — Gitea redirects releases/latest to
the newest published release's tag page. To install a particular version
instead, set VERSION=<version> yourself with the one you want; the tarball
filenames carry the version either way, so there is no version-free download
URL for them.
Verify before you extract. The signature is over SHA256SUMS.txt, and
SHA256SUMS.txt is over the tarballs:
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
sha256sum -c --ignore-missing SHA256SUMS.txt
tar -xzf "nxdns-$VERSION-x86_64-linux-musl.tar.gz"
Verify a release has the whole procedure: where the public key comes from, what fingerprint to expect, what each failure means, and what the signature does and does not prove. Read it once before your first install.
The extracted directory nxdns-$VERSION-x86_64-linux-musl/ holds everything
this page installs:
| File | What it is |
|---|---|
nxdns |
The static binary, mode 0755 |
nxdns.service |
The systemd unit |
nxdns.conf |
The sysusers fragment that creates the nxdns user |
LICENSE |
EUPL-1.2 |
THIRD-PARTY-NOTICES |
Licences of everything compiled or bundled in |
INSTALL.md |
A short version of this page |
Not verified on this host: no release exists yet, so none of these commands could be run against one — the
releases/latestlookup returns 404 for this repository and leavesVERSIONempty. The same lookup was run againstgitea.com/gitea/teaon Gitea1.27.0+devand printed0.15.1.
2. Copy the files to the target
cd "nxdns-$VERSION-x86_64-linux-musl"
scp nxdns nxdns.service nxdns.conf target:/tmp/
For a Raspberry Pi 5, extract the aarch64-linux-musl tarball instead — see
Raspberry Pi 5 below.
Not verified on this host:
targetis a placeholder for your server's hostname, and the machine that wrote this page has no second host to copy to.
Before copying, you can confirm the unit file parses:
systemd-analyze verify 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.
Verified on this host against
deploy/systemd/nxdns.servicein a checkout, which is the same file the tarball ships — the path is the only difference.
3. Install the binary, the user and the unit
Run as root on the target:
install -m 0755 /tmp/nxdns /usr/local/bin/nxdns
install -m 0644 /tmp/nxdns.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
The sysusers fragment ships under the name it is installed as, so there is no rename to get wrong.
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 configuration
nxdns will not start with nothing to forward to. Write /etc/nxdns/config.zon.
The smallest file 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" },
}
That file holds a password in plain text, so restrict it as soon as you have written it:
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. A root-owned 0600 file would be unreadable to it.
Keep that group read bit for good, not just for the first boot. Under
run --config the service reads this file on every start, so tightening
the mode later breaks the next restart. Under database authority it is
nxdns import that reads the file, as whoever runs that command, and a bare
nxdns run never reads it at all.
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:
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.
Now load it into the database:
nxdns import /etc/nxdns/config.zon
info(migrations): config.db migrated from schema version 0 to 1
imported /etc/nxdns/config.zon
The plaintext password is hashed into web.password_hash and never stored as
plaintext; nxdns export writes .password = null beside the hash. Nothing
downstream reads the plaintext again, so once step 6 confirms you can log in you
can delete the file:
rm /etc/nxdns/config.zon
A kept file is not a backup — nxdns export is (see
Back up and restore), and the export carries the
password hash rather than the password. If you keep it, leave it at 0640
root:nxdns.
That is the database mode install, which is what the packaged unit runs:
ExecStart=/usr/local/bin/nxdns run, no --config, so nothing reads a file
after this step. Change settings afterwards through the admin interface, the
API, or an export–edit–import cycle.
If you would rather keep /etc/nxdns/config.zon in git and have every restart
converge onto it, do not delete the file — go to
Run in file mode instead, and skip the rm.
Verified on this host, with a scratch
--configand--data-dirin place of/etc/nxdnsand/var/lib/nxdns— those two paths are the only difference from the blocks above. A file written under umask 022 came out 0644,nxdns check --configon it printedOK: no problems foundwith no mode warning,nxdns importof it printed the migration line andimported <path>and exited 0, and a followingnxdns exportwrote.password = nullnext to a populated.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…". Thechown,chmodandrmlines are ordinary root-owned-file operations and were not run against a real/etc/nxdns, which this host does not have.
5. Start it
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 <version> 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.
The unit does not restart the service after exit 2 or exit 64
(RestartPreventExitStatus=2 64). Those are a wrong configuration and a wrong
command line, and neither clears on a retry — restarting every two seconds until
StartLimitBurst gives up would only bury the diagnostics that are already in
the journal. systemctl status nxdns shows the failed state; fix the cause and
start it again.
If the start fails, read Troubleshoot nxdns. The two common
first-install failures are a port 53 already held by systemd-resolved and a
configuration file that does not parse.
6. Confirm it answers
From another machine on the LAN:
dig @<server-ip> example.com A +short
The admin interface is on port 8080 by default; log in with the password from
the configuration file. http://<server-ip>:8080/api/health reports upstream
availability and disk state without a login.
Not verified on this host as written:
<server-ip>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 +shortreturned the A records, andcurlagainst the web port returned 200. Only the address and the port differ from the lines above.
Run in file mode
In file mode /etc/nxdns/config.zon is the configuration: every start converges
the database onto it, and the admin interface refuses configuration edits with a
403 naming the file. Use it when you want the file in git and deployed by
Ansible. Stay in database mode when you want the UI to be the way things change.
The packaged unit is flagless on purpose — it is correct as shipped, and a
commented-out alternative ExecStart in a unit file is documentation
masquerading as configuration. File mode is a drop-in.
Adopt file mode on a box that is already running
Run these in order. Stop first, and do not skip that: any edit made through
the UI between an export and the restart would be silently reverted by the first
reconcile, and nxdns check against a live database refuses to grade it (below).
If you are arriving here from an upgrade, the binary must already be the new
one before you export. An export written by 0.0.1 carries a .password = ""
line this binary refuses; see
the order trap.
systemctl stop nxdns
nxdns export --out /etc/nxdns/config.zon
nxdns check --config /etc/nxdns/config.zon
wrote /etc/nxdns/config.zon
checking configuration file /etc/nxdns/config.zon
OK upstreams[0] https://cloudflare-dns.com
OK: no problems found
Then add the drop-in and start:
mkdir -p /etc/systemd/system/nxdns.service.d
cat > /etc/systemd/system/nxdns.service.d/file-mode.conf <<'EOF'
[Service]
ExecStart=
ExecStart=/usr/local/bin/nxdns run --config=/etc/nxdns/config.zon
EOF
systemctl daemon-reload
systemctl start nxdns
The empty ExecStart= is required. Without it systemd appends a second command
to the list rather than replacing the first, and the unit tries to run nxdns
twice.
The first start after adoption changes nothing, because the file was rendered from the database it is now governing:
reconciled '/etc/nxdns/config.zon': no changes
info(nxdns): authority: file (/etc/nxdns/config.zon)
info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
authority: file is the line that confirms the drop-in took. Blocklists,
compiled snapshots and client history all survive, and every later start on an
unchanged file writes nothing either.
The file now carries web.password_hash, so restrict it the same way step 4
does — chown root:nxdns, chmod 0640. The unit's ReadOnlyPaths=/etc/nxdns
denies the service write access to that directory, so the process that reads the
file cannot modify it.
Change the configuration from now on
Edit the file, validate it, restart:
$EDITOR /etc/nxdns/config.zon
nxdns check --config /etc/nxdns/config.zon
systemctl restart nxdns
Make nxdns check --config the precondition of any Ansible handler that
restarts nxdns. A file-mode start reads the file on every boot, so a bad
push that skips its handler does not fail at deploy time — it detonates at the
next power cut. Validating before restarting turns that into a failed deploy at
noon.
The restart prints what it changed:
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +0 ~2 -0;
settings keys changed: dns.port web.port
Leave file mode
Remove the drop-in and restart. The database already holds the last reconciled state, so nothing else is needed and the server comes back serving the same configuration:
rm /etc/systemd/system/nxdns.service.d/file-mode.conf
systemctl daemon-reload
systemctl restart nxdns
info(nxdns): authority: database
Verified on this host end to end, against a scratch
--data-dirand a scratch configuration path instead of/var/lib/nxdnsand/etc/nxdns, on unprivileged ports — this machine has neither of those directories, no root, and no installed unit. Everynxdnsline above was run and produced the output shown, with only those paths and the port numbers in theserving online differing.The run: a database-mode instance was started, a blocklist source was added through the API to make it UI-configured, then
nxdns checkagainst the live database printed the uncheckpointed-log FAIL and exited 2 (which is why this section stops the service first). After the stop,nxdns export --outwrote the file,nxdns check --configon it exited 0 withOK: no problems found, and the first file-mode start printedreconciled '<path>': no changesandauthority: file (<path>). A second file-mode start printedno changesagain and loaded the 3096006-byte compiled blocklist from disk with no download. Dropping the flag printedauthority: databaseand served the same configuration.The
systemctl,mkdir,cat > …/file-mode.confandrmlines need root and an installed unit and were not run. What was checked instead:systemd-analyze verifyondeploy/systemd/nxdns.servicewith those exact twoExecStartlines appended, which reported only the usualCommand /usr/local/bin/nxdns is not executablefor the absent binary and nothing about the override.
Raspberry Pi 5
The Pi 5 is aarch64. Nothing about the procedure changes except which tarball you take:
curl -fLO "$BASE/releases/download/v$VERSION/nxdns-$VERSION-aarch64-linux-musl.tar.gz"
sha256sum -c --ignore-missing SHA256SUMS.txt
tar -xzf "nxdns-$VERSION-aarch64-linux-musl.tar.gz"
cd "nxdns-$VERSION-aarch64-linux-musl"
scp nxdns nxdns.service nxdns.conf pi:/tmp/
Then follow steps 3 to 6 on the Pi.
Not verified on this host: no release exists to download,
piis a placeholder for your Pi's hostname, and this page was written on an x86_64 machine with no Pi attached.
Build from source instead
You do not need this to install nxdns, and it gets you a binary nobody has signed. It is here for two cases: you want to run something other than a tagged release, or you want to build the release yourself and compare it against the published one. For the second case, follow Verify a release rather than this section — it says what the comparison is and is not worth.
Requires Zig 0.16.0 and Node.js. From the repository root:
(cd web && npm ci && npm run build)
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)" \
-Dweb-dist=web/dist -Doptimize=ReleaseSafe
The first command builds the admin interface into web/dist; the last one
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. dist refuses to run against the web/dist-placeholder default for
exactly that reason, so there is no way to skip it by accident.
-Dversion-string is required and has no default. It is what nxdns version
prints. Take it from build.zig.zon rather than inventing one: verify-dist
asserts that the version under build equals .version there, so a made-up
string like 0.0.0-local builds but then fails verification. -Dgit-commit
is what distinguishes your build from the published one of the same version.
What comes out under zig-out/dist/ is the same set a release publishes,
minus the signature and the image digest:
bin/<triple>/nxdns— the stripped static binary, one per targetstage/nxdns-<version>-<triple>/— the staged payload, one per targetnxdns-<version>-<triple>.tar.gz— one tarball per targetSHA256SUMS— the two tarball hashes. The release publishes this asSHA256SUMS.txt, with a third line for the image digest appended
The two targets are x86_64-linux-musl and aarch64-linux-musl. Both binaries
are statically linked and need nothing installed on the target host.
Check the result the same way the release pipeline does:
zig build verify-dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)" \
-Dweb-dist=web/dist -Doptimize=ReleaseSafe
verify-dist extracts each archive and asserts the ELF is static and within
the size budget, that the layout and file modes are exactly what step 1 lists,
and that nxdns version prints what was built. It exits non-zero on any
failure.
Verified on this host:
zig build distandzig build verify-distwere both run to completion with the version taken frombuild.zig.zon.distproduced the two tarballs,SHA256SUMSand the staged payloads described above;verify-distexited 0 with every check passing and the aarch64nxdns versioncheck skipped for want of-fqemu. Passing a made-up-Dversion-string=0.0.0-localwas also run:distsucceeded andverify-distthen failed withFAIL zon-version: build.zig.zon says '0.0.1', the build says '0.0.0-local', which is why this section reads the version out ofbuild.zig.zon.
From here, join the page at step 2 with the staged directory in place of the extracted one:
cd "zig-out/dist/stage/nxdns-$VERSION-x86_64-linux-musl"
scp nxdns nxdns.service nxdns.conf target:/tmp/
The aarch64 binary is built by the same command and needs no toolchain on the Pi.