537 lines
21 KiB
Markdown
537 lines
21 KiB
Markdown
# 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](../reference/cli.md); for what
|
||
each configuration field means, see
|
||
[the configuration reference](../reference/configuration.md).
|
||
|
||
> Verification: `systemd-analyze verify` was run on the machine that wrote this
|
||
> page. `nxdns check`, `nxdns import`, `nxdns export` 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.
|
||
>
|
||
> 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](verify-a-release.md) covers in
|
||
> full, and the URL shapes — including the `releases/latest` redirect the
|
||
> version is read from — were probed there against `gitea.com`, a public
|
||
> instance running the same Gitea series.
|
||
>
|
||
> The `zig build dist` and `zig build verify-dist` blocks 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.
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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](verify-a-release.md) 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/latest` lookup returns 404 for this
|
||
> repository and leaves `VERSION` empty. The same lookup was run against
|
||
> `gitea.com/gitea/tea` on Gitea `1.27.0+dev` and printed `0.15.1`.
|
||
|
||
## 2. Copy the files to the target
|
||
|
||
```sh
|
||
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](#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.
|
||
|
||
Before copying, you can confirm the unit file parses:
|
||
|
||
```sh
|
||
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.service` in 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:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```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.
|
||
|
||
Now load it into the database:
|
||
|
||
```sh
|
||
nxdns import /etc/nxdns/config.zon
|
||
```
|
||
|
||
```
|
||
info(migrations): config.db migrated from schema version 0 to 2
|
||
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:
|
||
|
||
```sh
|
||
rm /etc/nxdns/config.zon
|
||
```
|
||
|
||
A kept file 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. 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](#run-in-file-mode) instead, and skip the `rm`.
|
||
|
||
> Verified on this host, with a scratch `--config` and `--data-dir` in place of
|
||
> `/etc/nxdns` and `/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 --config` on it printed `OK: no problems found` with no mode
|
||
> warning, `nxdns import` of it printed the migration line and `imported <path>`
|
||
> and exited 0, and a following `nxdns export` wrote `.password = null` next to
|
||
> a populated `.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…"`. The
|
||
> `chown`, `chmod` and `rm` lines are 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 <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](troubleshoot.md). 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:
|
||
|
||
```sh
|
||
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 +short` returned the A records, and `curl` against 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](upgrade.md#the-order-trap-export-with-the-new-binary-not-the-old-one).
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
$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:
|
||
|
||
```sh
|
||
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-dir` and a scratch
|
||
> configuration path instead of `/var/lib/nxdns` and `/etc/nxdns`, on
|
||
> unprivileged ports — this machine has neither of those directories, no root,
|
||
> and no installed unit. Every `nxdns` line above was run and produced the output
|
||
> shown, with only those paths and the port numbers in the `serving on` line
|
||
> differing.
|
||
>
|
||
> The run: a database-mode instance was started, a blocklist source was added
|
||
> through the API to make it UI-configured, then `nxdns check` against 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 --out` wrote
|
||
> the file, `nxdns check --config` on it exited 0 with `OK: no problems found`,
|
||
> and the first file-mode start printed `reconciled '<path>': no changes` and
|
||
> `authority: file (<path>)`. A second file-mode start printed `no changes`
|
||
> again and loaded the 3096006-byte compiled blocklist from disk with no
|
||
> download. Dropping the flag printed `authority: database` and served the same
|
||
> configuration.
|
||
>
|
||
> The `systemctl`, `mkdir`, `cat > …/file-mode.conf` and `rm` lines need root and
|
||
> an installed unit and were **not** run. What was checked instead:
|
||
> `systemd-analyze verify` on `deploy/systemd/nxdns.service` with those exact two
|
||
> `ExecStart` lines appended, which reported only the usual
|
||
> `Command /usr/local/bin/nxdns is not executable` for 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:
|
||
|
||
```sh
|
||
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, `pi` is 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](verify-a-release.md) 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:
|
||
|
||
```sh
|
||
(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 target
|
||
- `stage/nxdns-<version>-<triple>/` — the staged payload, one per target
|
||
- `nxdns-<version>-<triple>.tar.gz` — one tarball per target
|
||
- `SHA256SUMS` — the two tarball hashes. The release publishes this as
|
||
`SHA256SUMS.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:
|
||
|
||
```sh
|
||
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 dist` and `zig build verify-dist` were both
|
||
> run to completion with the version taken from `build.zig.zon`. `dist`
|
||
> produced the two tarballs, `SHA256SUMS` and the staged payloads described
|
||
> above; `verify-dist` exited 0 with every check passing and the aarch64
|
||
> `nxdns version` check skipped for want of `-fqemu`. Passing a made-up
|
||
> `-Dversion-string=0.0.0-local` was also run: `dist` succeeded and
|
||
> `verify-dist` then failed with
|
||
> `FAIL 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 of `build.zig.zon`.
|
||
|
||
From here, join the page at step 2 with the staged directory in place of the
|
||
extracted one:
|
||
|
||
```sh
|
||
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.
|