docs: unwrap hand-wrapped prose repo-wide
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m38s
Gates / package (push) Successful in 5m5s
Gates / test-aarch64 (push) Successful in 6m30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m30s

This commit is contained in:
2026-08-15 16:27:36 +02:00
parent 50b8fd5c61
commit 5b3d1cd65c
48 changed files with 2691 additions and 11699 deletions
+29 -101
View File
@@ -1,14 +1,10 @@
# Install nxdns with Docker
Runs the published nxdns image with Docker Compose. At the end a container
answers DNS on port 53 and keeps its data in a named volume.
Runs the published nxdns image with Docker Compose. At the end a container answers DNS on port 53 and keeps its data in a named volume.
The image is multi-architecture — `linux/amd64` and `linux/arm64` — so the same
tag works on a PC and on a Raspberry Pi 5. Building the image yourself is still
supported and is the last section of this page.
The image is multi-architecture — `linux/amd64` and `linux/arm64` — so the same tag works on a PC and on a Raspberry Pi 5. Building the image yourself is still supported and is the last section of this page.
For what each configuration field means, see
[the configuration reference](../reference/configuration.md).
For what each configuration field means, see [the configuration reference](../reference/configuration.md).
> Verification: the failure modes, the run and the two checks in step 3 were run
> on the machine that wrote an earlier revision of this page, against an image
@@ -43,16 +39,9 @@ VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
docker pull git.mial.net/mokhtar/nxdns:$VERSION
```
Pin a version. `:latest` exists and moves, which is what you want when you are
trying it out and not what you want on a machine your household's DNS depends
on. The lookup above asks the server for the current release rather than
hardcoding a number that goes stale one release later — Gitea redirects
`releases/latest` to the newest published release's tag page. To take a
particular version instead, set `VERSION=<version>` yourself.
Pin a version. `:latest` exists and moves, which is what you want when you are trying it out and not what you want on a machine your household's DNS depends on. The lookup above asks the server for the current release rather than hardcoding a number that goes stale one release later — Gitea redirects `releases/latest` to the newest published release's tag page. To take a particular version instead, set `VERSION=<version>` yourself.
Verify what you pulled before you run it. The release publishes an
`IMAGE-DIGEST.txt` asset naming the digest of the image index, and that file is
covered by the signed `SHA256SUMS.txt`:
Verify what you pulled before you run it. The release publishes an `IMAGE-DIGEST.txt` asset naming the digest of the image index, and that file is covered by the signed `SHA256SUMS.txt`:
```sh
curl -fLO "$BASE/releases/download/v$VERSION/IMAGE-DIGEST.txt"
@@ -65,11 +54,7 @@ docker buildx imagetools inspect git.mial.net/mokhtar/nxdns:$VERSION \
cut -d@ -f2 IMAGE-DIGEST.txt
```
The last two have to print the same string — `IMAGE-DIGEST.txt` holds a whole
pinned reference, `name:tag@sha256:…`, so the `cut` is what reduces it to the
digest `imagetools` prints. [Verify a release](verify-a-release.md)
covers the key, the fingerprint, every failure message, and what the signature
does and does not prove.
The last two have to print the same string — `IMAGE-DIGEST.txt` holds a whole pinned reference, `name:tag@sha256:…`, so the `cut` is what reduces it to the digest `imagetools` prints. [Verify a release](verify-a-release.md) covers the key, the fingerprint, every failure message, and what the signature does and does not prove.
> Not verified on this host: no image and no release are published yet, so
> `docker pull` and every URL here fail today, and the `releases/latest` lookup
@@ -80,10 +65,7 @@ does and does not prove.
## 2. Get the compose file and write the configuration
Every path on this page is relative to a checkout of the repository, because
that is how it was verified. Running the published image needs no checkout,
though — one file is enough. Fetch it for the version you pulled and work in
its directory instead, dropping `deploy/docker/` from the paths below:
Every path on this page is relative to a checkout of the repository, because that is how it was verified. Running the published image needs no checkout, though — one file is enough. Fetch it for the version you pulled and work in its directory instead, dropping `deploy/docker/` from the paths below:
```sh
mkdir -p ~/nxdns && cd ~/nxdns
@@ -94,16 +76,14 @@ curl -fLO "$BASE/raw/tag/v$VERSION/deploy/docker/compose.yaml"
> `<repo>/raw/tag/<tag>/<path>` was run here against `gitea.com/gitea/tea` on
> Gitea `1.27.0+dev` and returned the file with a 200.
Compose bind-mounts `deploy/docker/etc-nxdns` read-only at `/etc/nxdns`. Create
it and put the configuration in it:
Compose bind-mounts `deploy/docker/etc-nxdns` read-only at `/etc/nxdns`. Create it and put the configuration in it:
```sh
mkdir -p deploy/docker/etc-nxdns
$EDITOR deploy/docker/etc-nxdns/config.zon
```
The smallest file that starts is one group named `default` and one enabled
upstream:
The smallest file that starts is one group named `default` and one enabled upstream:
```zon
.{
@@ -113,15 +93,9 @@ upstream:
}
```
**The compose file ships file mode**, with
`command: ["run", "--config=/etc/nxdns/config.zon"]`. That file is the
configuration: the container reconciles its database onto it at every start, and
the admin interface answers 403 to configuration edits. To change anything, edit
the file and restart the container. It also means a fresh or recreated
`nxdns-data` volume rebuilds itself from the mounted file with no extra step.
**The compose file ships file mode**, with `command: ["run", "--config=/etc/nxdns/config.zon"]`. That file is the configuration: the container reconciles its database onto it at every start, and the admin interface answers 403 to configuration edits. To change anything, edit the file and restart the container. It also means a fresh or recreated `nxdns-data` volume rebuilds itself from the mounted file with no extra step.
The file is therefore required, and its absence is a hard failure rather than a
start with defaults:
The file is therefore required, and its absence is a hard failure rather than a start with defaults:
```
FAIL /etc/nxdns/config.zon: no such file
@@ -129,9 +103,7 @@ nxdns run failed: ManagedConfigUnreadable
run `nxdns check` to see the configuration in full
```
A file that is present but rejected is a different failure with the same exit
code. No `default` group, no enabled upstream, a syntax error — `run` prints the
diagnostic and exits 2 as well. A file whose only group was named `other`:
A file that is present but rejected is a different failure with the same exit code. No `default` group, no enabled upstream, a syntax error — `run` prints the diagnostic and exits 2 as well. A file whose only group was named `other`:
```
FAIL groups: no group named 'default'; every unknown client is assigned to it
@@ -139,24 +111,17 @@ nxdns run failed: MissingDefaultGroup
run `nxdns check` to see the configuration in full
```
Under `restart: unless-stopped` any of these is a restart loop — Docker has no
start limit and will retry forever. Read the lines above the failure, which name
the fault. See [Troubleshoot nxdns](troubleshoot.md).
Under `restart: unless-stopped` any of these is a restart loop — Docker has no start limit and will retry forever. Read the lines above the failure, which name the fault. See [Troubleshoot nxdns](troubleshoot.md).
### Database mode in Docker instead
Drop the `command:` line from `compose.yaml` and the container runs
`nxdns run`, with the database as the configuration and the file read by nothing.
On a fresh volume that database is empty and the container exits 2 with
`NoUsableUpstreams`, so load it once before bringing the service up:
Drop the `command:` line from `compose.yaml` and the container runs `nxdns run`, with the database as the configuration and the file read by nothing. On a fresh volume that database is empty and the container exits 2 with `NoUsableUpstreams`, so load it once before bringing the service up:
```sh
docker compose -f deploy/docker/compose.yaml run --rm nxdns import /etc/nxdns/config.zon
```
The file is positional; add `--allow-delete` when re-running it against a
populated volume and the diff deletes rows. Without this step, `restart:
unless-stopped` plus exit 2 is a crash loop with no way out.
The file is positional; add `--allow-delete` when re-running it against a populated volume and the diff deletes rows. Without this step, `restart: unless-stopped` plus exit 2 is a crash loop with no way out.
> Not run in a container on this host, for the reason in the verification note
> at the top: no image could be staged here. The `nxdns import <file>` and
@@ -165,10 +130,7 @@ unless-stopped` plus exit 2 is a crash loop with no way out.
> exited 0, the second was required after a plain `import` refused a
> row-deleting file with `DestructiveImport` and exited 2.
The container runs as uid 65532, and the mount is read-only, so the container
cannot repair permissions itself. Mode 0644 works and was used here. If the
file carries a secret — `web.password`, or a `web.password_hash` from a
restored export — give it to that uid instead:
The container runs as uid 65532, and the mount is read-only, so the container cannot repair permissions itself. Mode 0644 works and was used here. If the file carries a secret — `web.password`, or a `web.password_hash` from a restored export — give it to that uid instead:
```sh
chown 65532:65532 deploy/docker/etc-nxdns/config.zon
@@ -190,10 +152,7 @@ NXDNS_VERSION=$VERSION docker compose -f deploy/docker/compose.yaml up -d
docker compose -f deploy/docker/compose.yaml logs -f
```
`compose.yaml` reads the image from two variables:
`${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}}`. Set
`NXDNS_VERSION` to pin a release; set `NXDNS_IMAGE` to run something else
entirely, which is what the build-from-source section at the bottom does.
`compose.yaml` reads the image from two variables: `${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-latest}}`. Set `NXDNS_VERSION` to pin a release; set `NXDNS_IMAGE` to run something else entirely, which is what the build-from-source section at the bottom does.
> Verified on this host with `docker compose -f deploy/docker/compose.yaml
> config`, which resolves the variables without contacting a registry: no
@@ -201,12 +160,7 @@ entirely, which is what the build-from-source section at the bottom does.
> gives `git.mial.net/mokhtar/nxdns:0.0.1`, and `NXDNS_IMAGE=nxdns` gives
> `nxdns`.
Every block on this page runs from the repository root, and none of them change
directory, so they can be pasted in order. `-f` is what makes that work:
Compose resolves the relative paths inside `compose.yaml` — the `etc-nxdns`
bind mount — against the directory holding the file, not against your shell,
and it takes the project name `docker` from that directory either way, which is
why the container is `docker-nxdns-1`.
Every block on this page runs from the repository root, and none of them change directory, so they can be pasted in order. `-f` is what makes that work: Compose resolves the relative paths inside `compose.yaml` — the `etc-nxdns` bind mount — against the directory holding the file, not against your shell, and it takes the project name `docker` from that directory either way, which is why the container is `docker-nxdns-1`.
A healthy first start logs the reconcile, the authority and the bound sockets:
@@ -220,8 +174,7 @@ info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53;
info(web_server): web interface listening on 0.0.0.0:8080
```
Every later start on an unchanged file reports `reconciled
'/etc/nxdns/config.zon': no changes` and writes nothing to the database.
Every later start on an unchanged file reports `reconciled '/etc/nxdns/config.zon': no changes` and writes nothing to the database.
Confirm it answers and that the admin interface is up:
@@ -240,29 +193,17 @@ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
> host port is occupied; free the port or edit the `ports:` list. The image
> under test was built locally, not pulled: there is nothing published to pull.
The compose file publishes 53/udp, 53/tcp and 8080, keeps `/var/lib/nxdns` in
the named volume `nxdns-data`, and sets the per-namespace sysctl
`net.ipv4.ip_unprivileged_port_start=0` so uid 65532 can bind port 53 without
any capability. Uncomment the 443 and 853 mappings when you enable the DoH or
DoT listener; see [Enable DoH and DoT](enable-doh-and-dot.md).
The compose file publishes 53/udp, 53/tcp and 8080, keeps `/var/lib/nxdns` in the named volume `nxdns-data`, and sets the per-namespace sysctl `net.ipv4.ip_unprivileged_port_start=0` so uid 65532 can bind port 53 without any capability. Uncomment the 443 and 853 mappings when you enable the DoH or DoT listener; see [Enable DoH and DoT](enable-doh-and-dot.md).
## 4. Do not point the host at the container
The container resolves its own upstream DoH and DoT hostnames through the
host's DNS configuration. If you set the host's `/etc/resolv.conf` to the nxdns
container, the container's startup lookups depend on the service that is trying
to start. Point LAN clients at nxdns; leave the container's host on its own
resolver.
The container resolves its own upstream DoH and DoT hostnames through the host's DNS configuration. If you set the host's `/etc/resolv.conf` to the nxdns container, the container's startup lookups depend on the service that is trying to start. Point LAN clients at nxdns; leave the container's host on its own resolver.
## Raspberry Pi 5
Nothing changes. The published tag is a multi-architecture index, so
`docker pull` on the Pi selects the `linux/arm64` image on its own. The
platform list is one of the things
[Verify a release](verify-a-release.md) has you check.
Nothing changes. The published tag is a multi-architecture index, so `docker pull` on the Pi selects the `linux/arm64` image on its own. The platform list is one of the things [Verify a release](verify-a-release.md) has you check.
To pull the arm64 image from an x86_64 machine — to inspect it, or to save and
copy it — name the platform:
To pull the arm64 image from an x86_64 machine — to inspect it, or to save and copy it — name the platform:
```sh
docker pull --platform linux/arm64 git.mial.net/mokhtar/nxdns:$VERSION
@@ -274,9 +215,7 @@ docker pull --platform linux/arm64 git.mial.net/mokhtar/nxdns:$VERSION
## Build the image from source instead
The Dockerfile does not compile anything. It assembles a filesystem around
binaries you build first, so build the admin interface and the release
artifacts from the repository root:
The Dockerfile does not compile anything. It assembles a filesystem around binaries you build first, so build the admin interface and the release artifacts from the repository root:
```sh
(cd web && npm ci && npm run build)
@@ -286,19 +225,11 @@ zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)"
DOCKER_BUILDKIT=1 docker build -t nxdns -f deploy/docker/Dockerfile .
```
Take the version from `build.zig.zon` rather than inventing one: `verify-dist`
asserts the two agree, so a made-up string builds but fails verification.
BuildKit is required — the Dockerfile pins its builder stage to
`$BUILDPLATFORM`, which the classic builder does not define.
Take the version from `build.zig.zon` rather than inventing one: `verify-dist` asserts the two agree, so a made-up string builds but fails verification. BuildKit is required — the Dockerfile pins its builder stage to `$BUILDPLATFORM`, which the classic builder does not define.
Build `web/dist` every time, before the binaries. A stale bundle is embedded
silently and ships an admin interface that does not match its API — which is
why `dist` refuses to build against the `web/dist-placeholder` default at all.
Build `web/dist` every time, before the binaries. A stale bundle is embedded silently and ships an admin interface that does not match its API — which is why `dist` refuses to build against the `web/dist-placeholder` default at all.
The context has to be the repository root, because the Dockerfile copies
`zig-out/dist/bin` and `zig-out/dist/stage`. The result is a `scratch` image
holding the binary, a CA bundle, `/LICENSE`, `/THIRD-PARTY-NOTICES` and two
empty directories.
The context has to be the repository root, because the Dockerfile copies `zig-out/dist/bin` and `zig-out/dist/stage`. The result is a `scratch` image holding the binary, a CA bundle, `/LICENSE`, `/THIRD-PARTY-NOTICES` and two empty directories.
Run that image instead of the published one by naming it:
@@ -306,16 +237,13 @@ Run that image instead of the published one by naming it:
NXDNS_IMAGE=nxdns docker compose -f deploy/docker/compose.yaml up -d
```
For an arm64 image on an x86_64 machine, use buildx. The Dockerfile's builder
stage is pinned to `$BUILDPLATFORM` and only copies files, so no emulation is
involved:
For an arm64 image on an x86_64 machine, use buildx. The Dockerfile's builder stage is pinned to `$BUILDPLATFORM` and only copies files, so no emulation is involved:
```sh
docker buildx build --platform linux/arm64 -t nxdns:arm64 -f deploy/docker/Dockerfile .
```
Add `--push` or `--load` to keep the result; the default buildx driver leaves
it in the build cache.
Add `--push` or `--load` to keep the result; the default buildx driver leaves it in the build cache.
> Verified on this host, except the two buildx lines. `zig build dist` was run
> to completion with the version read out of `build.zig.zon` and exited 0, and