15 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: on 2026-10-02, steps 1 to 3 were run against the published
v0.0.21release with an image pulled anonymously from the registry, on the host ports printed below; the notes in those steps carry the detail. The failure modes in step 2 were run on the machine that wrote an earlier revision of this page, against an image built from this checkout. Thechownto uid 65532 needs root and was not run.Earlier, for the file-mode revision. The compose file now ships
command: ["run", "--config=/etc/nxdns/config.zon"], and no container was started against that command on this host: staging a release image needszig build dist, which refuses to run whileadmin/distis stale, and the web bundle was being rebuilt by other work in the same tree at the time. What was checked instead isdocker compose -f deploy/docker/compose.yaml config, which resolves the file without contacting a registry and prints thecommandand the:robind mount as written, and the samerun --config=<file>invocation driven directly against the locally built binary: it printed the reconcile summary,authority: file (<path>), andno changeson the second start. The log lines quoted in step 3 come from that run, with the paths and ports the container uses.
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.
Verified on 2026-10-02 against
v0.0.21, with an emptyDOCKER_CONFIG, so no registry credentials: the lookup printed0.0.21,docker pullsucceeded without a login,gpg --verifyprintedGood signaturewith the primary fingerprint above,sha256sum -cprintedOKfor all three files, andimagetools inspectand thecutboth printedsha256:ce1fef32c7ee6c23656abd94e53e4d4d6b1d34fe88a2c842b82c7202d8b904c3.
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:
mkdir -p ~/nxdns && cd ~/nxdns
curl -fLO "$BASE/raw/tag/v$VERSION/deploy/docker/compose.yaml"
Verified on 2026-10-02: the
curlreturnedcompose.yamlfrom thev0.0.21tag with a 200.
Compose bind-mounts deploy/docker/etc-nxdns read-only at /etc/nxdns. Create it and put the configuration 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" },
}
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:
FAIL /etc/nxdns/config.zon: no such file
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:
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
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.
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:
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.
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>andnxdns import <file> --allow-deletecommands inside it were run directly against the locally built binary — the first applied an additive file and exited 0, the second was required after a plainimportrefused a row-deleting file withDestructiveImportand 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:
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 configuration file at 0600 owned by another uid makes the container refuse to start and restart in a loop. In file mode an unreadable file is a configuration fault:FAIL /etc/nxdns/config.zon: not readablefollowed bynxdns run failed: ManagedConfigUnreadable, exit 2. See Troubleshoot nxdns.
3. Run it
docker compose -f deploy/docker/compose.yaml up -d
docker compose -f deploy/docker/compose.yaml logs -f
compose.yaml names the release it was tagged with: its image is ${NXDNS_IMAGE:-git.mial.net/mokhtar/nxdns:${NXDNS_VERSION:-<release>}}, and zig build cut writes <release> in the same commit that bumps build.zig.zon, so the file from the v$VERSION tag runs $VERSION with no variable set. Set NXDNS_VERSION to run a different release with the same file; set NXDNS_IMAGE to run something else entirely, which is what the build-from-source section at the bottom does. Compose files from v0.0.22 and earlier default to :latest instead, so with one of those, set NXDNS_VERSION=$VERSION on every command.
Verified on 2026-10-20 with
docker compose -f deploy/docker/compose.yaml configon a tree whosebuild.zig.zondeclares0.0.22, which resolves the variables without contacting a registry: no variables givesgit.mial.net/mokhtar/nxdns:0.0.22,NXDNS_VERSION=0.0.21givesgit.mial.net/mokhtar/nxdns:0.0.21, 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 reconcile, the authority and the bound sockets:
info(migrations): config.db migrated from schema version 0 to 1
reconciled '/etc/nxdns/config.zon': upstreams +1 ~0 -0; settings +46 ~0 -0;
settings keys changed: dns.bind_ipv4 dns.bind_ipv6 dns.port web.bind web.port …
web authentication is now enabled
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
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.
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/
Verified on 2026-10-02 with the pulled
v0.0.21image, the configuration above and the unchangedcompose.yaml, on host ports 53 and 8080: the first start logged the lines above,digreturned the A records forexample.com,curlreturned 200, and/api/healthreported"status":"ok"with one of one upstreams available.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 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
Verified on 2026-10-02 against
v0.0.21:imagetools inspectlistslinux/amd64andlinux/arm64, and the pull above fetched an image whosedocker image inspectreportslinux/arm64. It was not started: this host is x86_64 with no emulation.
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 admin && npm ci && npm run build)
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" \
-Dadmin-dist=admin/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 admin/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 admin/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.