Skip to content

Install with Docker

The image is ghcr.io/operomtl/opdns-node, for linux/amd64 and linux/arm64. It contains the static node binary and Alpine’s Unbound, which the node starts and supervises. It runs as an unprivileged user that may bind port 53. Data lives in the volume at /var/lib/opdns.

Tags: each release is tagged with its version (1.2.3, 1.2, v1.2.3), and latest points at the newest stable release. Pin a version for anything you care about.

On many Linux distributions systemd-resolved listens on 127.0.0.53:53, which conflicts with publishing port 53 on all addresses. Either publish on the LAN address only (-p 192.168.1.10:53:53/udp), or turn off its stub listener:

/etc/systemd/resolved.conf.d/no-stub.conf
[Resolve]
DNSStubListener=no

then sudo systemctl restart systemd-resolved.

The dashboard’s Nodes → Add a node dialog shows this command with your enrolment code filled in (see Enrol a node):

Terminal window
docker run -d --name opdns-node --restart unless-stopped \
-p 53:53/udp -p 53:53/tcp \
-p 127.0.0.1:8053:8053 \
-v opdns-node:/var/lib/opdns \
-e OPDNS_ENROL_TOKEN=<enrolment-code> \
ghcr.io/operomtl/opdns-node:<version>

The node installs a list artifact only if its Ed25519 signature verifies. You do not need to configure the key: by default (lists.trust_cloud_key: true) an enrolled node learns it from the cloud on every profile pull and keeps it in lists_keys.json in the volume, so it can verify lists after a restart without the internet. To pin keys yourself instead, set OPDNS_LISTS_PUBLIC_KEYS and OPDNS_LISTS_TRUST_CLOUD_KEY=false (list signing keys).

OPDNS_ENROL_TOKEN is used once, on first start; after that the node’s identity is in the volume and the variable is ignored. It is safe to leave in the Compose file, but the code is single-use and expires after 15 minutes anyway.

A container started with neither an enrolment code, an existing identity in its volume, nor a standalone profile does not serve DNS: it prints how to enrol and exits with status 2. With --restart unless-stopped it keeps restarting and printing the same message until you add the code.

  1. docker logs opdns-node shows the enrolment and “loaded” lines.
  2. docker exec opdns-node opdns-node status prints mode, node id, profile, link state and versions.
  3. Open http://127.0.0.1:8053/ on the host for the status page.
  4. dig @<host-address> example.com answers.
  5. The dashboard’s Nodes page shows the node online.

The image has a health check on /healthz; docker ps shows it as healthy once Unbound answers and a profile and lists are loaded.

Set your router’s DHCP DNS server (option 6, and IPv6 RDNSS if you use IPv6) to the node’s LAN address. Queries from every device then go to the node.

The node listens for plain DNS by default. For DNS-over-TLS, DoH, DoQ or DoH3 on your LAN, give it a certificate for a name your devices use and set the listeners, for example:

compose.yaml (excerpt)
ports:
- "853:853/tcp"
- "443:443/tcp"
volumes:
- opdns-node:/var/lib/opdns
- /etc/letsencrypt/live/dns.home.example:/certs:ro
environment:
OPDNS_LISTEN_DOT: ":853"
OPDNS_LISTEN_DOH: ":443"
OPDNS_LISTEN_TLS_CERT: /certs/fullchain.pem
OPDNS_LISTEN_TLS_KEY: /certs/privkey.pem
OPDNS_LISTEN_DNS_DOMAIN: dns.home.example

Renewal needs no restart. The node checks the certificate and key files every second and loads a new pair as soon as it changes; a pair that fails to load is logged and the previous certificate stays in use. Connections already open keep the certificate they started with. docker kill -s HUP opdns-node forces a reload (and a fresh profile and list pull, and applies the reloadable configuration keys). The log says certificate reloaded with the new expiry date.

Every key is on the configuration reference.