# 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](../reference/configuration.md). > 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 `chown` to uid 65532 needs > root and was not run. ## 1. Pull and verify the image ```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##') 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=` 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`: ```sh 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](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 > returns 404 and leaves `VERSION` empty. That lookup was run against > `gitea.com/gitea/tea` on Gitea `1.27.0+dev` and printed `0.15.1`. The > `docker buildx imagetools inspect --format` shape was run here against > `alpine:3.22` on 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: ```sh 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 > `/raw/tag//` 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 seed file 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: ```zon .{ .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](troubleshoot.md). 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 chmod 0600 deploy/docker/etc-nxdns/config.zon ``` > Not verified on this host: `chown` to 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 log `nxdns run failed: AccessDenied` and > restart in a loop. See [Troubleshoot nxdns](troubleshoot.md). ## 3. Run it ```sh 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 gives `git.mial.net/mokhtar/nxdns:latest`, `NXDNS_VERSION=0.0.1` > 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`. 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 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: ```sh 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 > ports `dig` returned the A records for `example.com` and `curl` returned 200. > `docker compose up -d` fails with > `failed to bind host port 0.0.0.0:8080/tcp: address already in use` when a > 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). ## 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](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: ```sh 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: ```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 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: ```sh 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: ```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. > 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 > the `DOCKER_BUILDKIT=1 docker build` above was then run against that > `zig-out/dist` tree and exited 0. The binary copied out of the resulting > image with `docker cp` hashed identically to > `zig-out/dist/stage/nxdns--x86_64-linux-musl/nxdns`. The > `NXDNS_IMAGE=nxdns docker compose ... up -d` line was verified as described in > step 3, with the host ports moved. `docker buildx build --platform > linux/arm64` was run here too and exited 0 on an x86_64 host with no > emulation available — the builder stage is pinned to `$BUILDPLATFORM` and the > final stage is `FROM scratch`, so nothing arm64 ever executes during the > build. It printed the driver's `No output specified` warning, which is the > reason the paragraph above tells you to add `--push` or `--load`. See > [Install with systemd](install-with-systemd.md#build-from-source-instead) for > the `dist` and `verify-dist` detail.