13 KiB
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.
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.
Verification: the seed-file failure modes, the run and the two checks in step 3 were run on the machine that wrote this page, against an image built from this checkout rather than pulled from the registry — no release is published yet, so nothing on this page could be run against a pulled image, and step 1 could not be run at all. One command was run in altered form: host port 8080 was occupied here, so the run and the verification commands in step 3 were executed with the host side of the port mappings moved to 25353 and 28088 rather than the 53 and 8080 printed below. The container side was unchanged. See the note in step 3. The
chownto uid 65532 needs root and was not run.
1. Pull and verify the image
BASE=https://git.mial.net/mokhtar/nxdns
VERSION=$(curl -fsS -o /dev/null -w '%{redirect_url}' "$BASE/releases/latest" |
sed 's#.*/releases/tag/v##')
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.
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:
curl -fLO "$BASE/releases/download/v$VERSION/IMAGE-DIGEST.txt"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt"
curl -fLO "$BASE/releases/download/v$VERSION/SHA256SUMS.txt.asc"
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
sha256sum -c --ignore-missing SHA256SUMS.txt
docker buildx imagetools inspect git.mial.net/mokhtar/nxdns:$VERSION \
--format '{{.Manifest.Digest}}'
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
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 pulland every URL here fail today, and thereleases/latestlookup returns 404 and leavesVERSIONempty. That lookup was run againstgitea.com/gitea/teaon Gitea1.27.0+devand printed0.15.1. Thedocker buildx imagetools inspect --formatshape was run here againstalpine:3.22on Docker Hub and printed that image's index digest.
2. Get the compose file and write the seed 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:
mkdir -p ~/nxdns && cd ~/nxdns
curl -fLO "$BASE/raw/tag/v$VERSION/deploy/docker/compose.yaml"
Not verified against nxdns: there is no tag yet. The Gitea raw-file URL shape
<repo>/raw/tag/<tag>/<path>was run here againstgitea.com/gitea/teaon Gitea1.27.0+devand returned the file with a 200.
Compose bind-mounts deploy/docker/etc-nxdns read-only at /etc/nxdns. Create
it and put the seed file in it:
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:
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
.web = .{ .password = "choose-a-real-password" },
}
Without that file the container exits with code 2 on a fresh volume: an empty
database has nothing to forward to. The log is
no configuration file at '/etc/nxdns/config.zon'; using the database as it is
followed by nxdns run failed: NoUsableUpstreams.
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. Both were run here against a locally built
image. A seed file whose only group was named other:
FAIL groups: no group named 'default'; every unknown client is assigned to it
nxdns run failed: MissingDefaultGroup
run `nxdns check` to see the configuration in full
and an empty /etc/nxdns:
info(config_bootstrap): no configuration file at '/etc/nxdns/config.zon'; using the database as it is
nxdns run failed: NoUsableUpstreams
run `nxdns check` to see the configuration in full
Under restart: unless-stopped either one is a restart loop, and the exit code
alone no longer tells them apart: read the lines above the failure, which either
name the diagnostic in the file or say there was no file at all. See
Troubleshoot nxdns.
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:
chown 65532:65532 deploy/docker/etc-nxdns/config.zon
chmod 0600 deploy/docker/etc-nxdns/config.zon
Not verified on this host:
chownto a uid you do not own needs root. What was verified is the failure it prevents — a seed file at 0600 owned by another uid makes the container lognxdns run failed: AccessDeniedand restart in a loop. See Troubleshoot nxdns.
3. Run it
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.
Verified on this host with
docker compose -f deploy/docker/compose.yaml config, which resolves the variables without contacting a registry: no variables givesgit.mial.net/mokhtar/nxdns:latest,NXDNS_VERSION=0.0.1givesgit.mial.net/mokhtar/nxdns:0.0.1, andNXDNS_IMAGE=nxdnsgivesnxdns.
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 seeding and the bound sockets:
info(config_bootstrap): seeded the database from '/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
info(web_server): web interface listening on 0.0.0.0:8080
Confirm it answers and that the admin interface is up:
dig @127.0.0.1 example.com A +short
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/
On the machine that wrote this page, host port 8080 was already taken by an unrelated process, so the container was verified with the host side of the port mappings moved to 25353 and 28088 — everything inside the container was unchanged, and the log still reads
serving on udp [::]:53. Against those portsdigreturned the A records forexample.comandcurlreturned 200.docker compose up -dfails withfailed to bind host port 0.0.0.0:8080/tcp: address already in usewhen a host port is occupied; free the port or edit theports: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.
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.
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 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:
docker pull --platform linux/arm64 git.mial.net/mokhtar/nxdns:$VERSION
Not verified on this host: nothing is published to pull, and this host is x86_64 with no emulation, so an arm64 image could not be started here even if it were.
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:
(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
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.
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.
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:
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.
Verified on this host, except the two buildx lines.
zig build distwas run to completion with the version read out ofbuild.zig.zonand exited 0, and theDOCKER_BUILDKIT=1 docker buildabove was then run against thatzig-out/disttree and exited 0. The binary copied out of the resulting image withdocker cphashed identically tozig-out/dist/stage/nxdns-<version>-x86_64-linux-musl/nxdns. TheNXDNS_IMAGE=nxdns docker compose ... up -dline was verified as described in step 3, with the host ports moved.docker buildx build --platform linux/arm64was run here too and exited 0 on an x86_64 host with no emulation available — the builder stage is pinned to$BUILDPLATFORMand the final stage isFROM scratch, so nothing arm64 ever executes during the build. It printed the driver'sNo output specifiedwarning, which is the reason the paragraph above tells you to add--pushor--load. See Install with systemd for thedistandverify-distdetail.