Skip to content

Configuration reference

Every setting and list nxdns accepts, with its type, default and allowed values.

The configuration model explains how the file, the database and the web interface relate. The CLI reference covers the commands that read and write configuration.

File format

The configuration file is ZON, Zig’s literal syntax: one .{ … } block whose fields are the sections and lists below. Strings are in double quotes, and choices are written with a leading dot (.response = .nxdomain). Comments start with //. The file may be at most 4 MiB, and a syntax error is reported with its line and column.

Anything the file leaves out keeps its default.

The smallest file that starts is a default group and one upstream:

.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
}

Settings

Settings are grouped in sections. The Key column gives a setting’s full name, as the web interface and nxdns check show it; in the file it sits inside its section, so dns.port is written .dns = .{ .port = 53 }.

Some settings take effect only after a restart; the web interface marks them needs restart.

upstream

How long nxdns waits for upstream resolvers.

KeyTypeDefaultAllowed valuesWhat it does
upstream.attempt_timeout_msinteger2500100–120000 ms, and not above total_timeout_msTime allowed for one try against one upstream, connecting included. When it runs out, nxdns tries the next upstream.
upstream.total_timeout_msinteger5000100–120000 msTime allowed for a whole query across every upstream it tries. When it runs out, the client gets SERVFAIL. Also the time nxdns check gives each upstream.
upstream.read_timeout_msinteger3000100–120000 msTime allowed for a query sent to a forward zone’s resolver, including a retry over TCP.

A query against several unreachable upstreams costs total_timeout_ms once, not one attempt_timeout_ms per upstream. An attempt that the total cut short is not held against that upstream’s health.

dns

The plain DNS listener, over UDP and TCP.

KeyTypeDefaultAllowed valuesWhat it does
dns.bind_ipv4string"0.0.0.0"an IPv4 addressThe IPv4 address to listen on.
dns.bind_ipv6string"::"an IPv6 addressThe IPv6 address to listen on.
dns.portinteger531–65535The port to listen on.
dns.rate_limitinteger1000at least 1How many queries one client may send per window.
dns.rate_window_secondsinteger601–3600 sThe length of that window.

Each bind address must belong to its own family: an IPv4 address in dns.bind_ipv6 is refused.

blocking

What a blocked query gets back.

KeyTypeDefaultAllowed valuesWhat it does
blocking.response.zero or .nxdomain.zero.zero answers 0.0.0.0 or ::; .nxdomain answers that the name does not exist.
blocking.ttlinteger50–86400 sHow long a client may cache a blocked answer.

cache

KeyTypeDefaultAllowed valuesWhat it does
cache.sizeinteger100001–1000000 entriesHow many answers nxdns caches. The memory for the cached answers grows with it, and when that memory is full, answers nobody has asked for lately make room for new ones.
cache.negative_ttl_maxinteger36000–86400 sThe longest nxdns caches an answer that a name does not exist; 0 turns that off.

The cache cannot be turned off; the smallest is cache.size = 1.

web

The web interface.

KeyTypeDefaultAllowed valuesWhat it does
web.enabledbooltrueRuns the web interface.
web.bindstring"0.0.0.0"an IP addressThe address it listens on.
web.portinteger80801–65535The port it listens on.
web.passwordstringnonenot empty; not together with password_hashSets the admin password. Only its hash is stored; see Passwords.
web.password_hashstringnonean argon2id hash, or ""The stored password hash. "" removes the password.
web.session_ttl_hoursinteger24at least 1How long a login lasts.
web.api_rate_limit_per_mininteger300at least 1How many requests one client may make to the web interface per minute.
web.api_localhost_exemptbooltrueRequests from the nxdns machine itself skip that limit.
web.sse_max_connections_per_ipinteger3at least 1How many live views (such as Activity’s Live tab) one client may keep open.
web.trusted_proxiesstring""IP addresses separated by commasReverse proxies whose X-Forwarded-For header nxdns believes; see Behind a reverse proxy.

doh_server

DNS over HTTPS for clients on your network. See Enable DoH and DoT.

KeyTypeDefaultAllowed valuesWhat it does
doh_server.enabledboolfalseRuns the DoH listener.
doh_server.bindstring"0.0.0.0"an IP addressThe address it listens on.
doh_server.portinteger4431–65535The port it listens on.
doh_server.cert_pathstring"/etc/nxdns/cert.pem"not empty when enabledThe certificate, as PEM. A renewed file is picked up within 30 seconds.
doh_server.key_pathstring"/etc/nxdns/key.pem"not empty when enabledIts private key, as PEM. nxdns check warns when others can read it.

dot_server

DNS over TLS for clients on your network.

KeyTypeDefaultAllowed valuesWhat it does
dot_server.enabledboolfalseRuns the DoT listener.
dot_server.bindstring"0.0.0.0"an IP addressThe address it listens on.
dot_server.portinteger8531–65535The port it listens on.
dot_server.cert_pathstring"/etc/nxdns/cert.pem"not empty when enabledThe certificate, as PEM.
dot_server.key_pathstring"/etc/nxdns/key.pem"not empty when enabledIts private key, as PEM.

edns

KeyTypeDefaultAllowed valuesWhat it does
edns.ecs_mode.strip or .forward.strip.strip removes the client’s subnet (EDNS Client Subnet) from queries before they go upstream; .forward passes it on.

logging

The process log and the query history.

KeyTypeDefaultAllowed valuesWhat it does
logging.level.err, .warn, .info or .debug.infoThe least important log line nxdns writes. The file says .err; the web interface says error.
logging.retention_daysinteger30at least 1How long the query history is kept, and how long a device that stopped asking stays in Clients.
logging.query_log_buffer_maxinteger10241–37449How many queries nxdns holds in memory while it waits to write them, each about 1.8 KiB. The default covers over a minute and a half of queries at 10 a second, far more than a household asks while a write is in progress. When the buffer is full, the oldest waiting query is dropped from the history and counted.
logging.query_log_flush_interval_sinteger600–3600 sHow often the query history is written to disk; see below.
logging.hide_domainsboolfalseStores a placeholder instead of the name in the query history.
logging.hide_client_ipsboolfalseStores a placeholder instead of the client’s address in the query history.
logging.output.stderr, .syslog or .file.stderrWhere the process log goes. .stderr and .syslog both write to standard error, which journald and Docker collect. .file writes to file_path and rotates it.
logging.file_pathstring"/var/log/nxdns/nxdns.log"an absolute path, when output is .fileThe log file. nxdns does not create its directory.
logging.max_size_mbinteger50at least 1The size at which the log file is rotated.
logging.max_filesinteger5at least 1How many log files are kept, the current one included.

Why the query history is written once a minute

Writing each query to disk as it happens wears out an SD card for no benefit, so nxdns writes the queries of the last query_log_flush_interval_s seconds together. Pi-hole’s default is the same minute, for the same reason.

The cost is that the History tab, Overview and the charts run up to a minute behind, and a crash loses up to that minute of history. Activity → Live is not delayed: it shows each query the moment it is answered. A burst of 100 queries is written at once without waiting, and stopping nxdns writes what it holds first.

0 writes queries as they arrive, if you want History current to the second and do not mind the extra writes.

disk

Free-space thresholds for the data directory. DNS keeps working whatever the free space.

KeyTypeDefaultAllowed valuesWhat it does
disk.min_free_mbinteger200at least 1, and not above warn_free_mbBelow this, nxdns stops writing query history and device records and stops downloading blocklists, until space comes back.
disk.warn_free_mbinteger500at least 1Below this, nxdns logs a warning.

blocklist_update

KeyTypeDefaultAllowed valuesWhat it does
blocklist_update.enabledbooltrueRefreshes blocklists on a schedule. When false, they refresh only at startup and when you press Update now.
blocklist_update.interval_hoursinteger24at least 1How often blocklists are refreshed.

Lists

The rest of the configuration is lists of entries. Fields with no default are required.

groups

Groups of clients that share blocklists and rules. A group named default is required: every client not assigned to another group is in it.

FieldTypeDefaultAllowed values
namestringrequirednot empty, at most 64 bytes, unique
safe_searchboolfalse

safe_search makes search engines that support it return their safe results to the group’s clients.

upstreams

The resolvers nxdns forwards queries to, over DoH (https://) or DoT (tls://). At least one must be enabled.

FieldTypeDefaultAllowed values
urlstringrequiredan https:// or tls:// URL; unique
priorityinteger100lower is tried first
enabledbooltrue
tls_namestring""tls:// only: the name the resolver’s certificate must carry

nxdns tries the upstreams in order of priority and moves to the next when one fails.

A tls:// URL must use an IP address, because nxdns would need DNS to look up its own resolver’s name. Put the name in tls_name so the certificate can still be checked:

.{ .url = "tls://9.9.9.9:853", .tls_name = "dns.quad9.net" },

An https:// URL takes a hostname as usual.

nxdns speaks DoH over HTTP/1.1, so a DoH server that requires HTTP/2 does not work. Quad9’s DoH endpoint is one: use Quad9 over DoT, as above.

clients

Devices with a fixed group, by address.

FieldTypeDefaultAllowed values
ipstringrequiredan IP address; unique
namestring""the name shown in the web interface
groupstring"default"a declared group

Names nxdns learns

A device you have not named can still show a name. If a forward zone covers your network’s reverse names, nxdns asks that resolver, usually your router, for each unnamed device’s name once a day, and shows the answer:

.forward_zones = .{
.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" },
},

A name you type always wins. Learned names are never exported, and a renamed device can show its old name for up to a day.

client_prefixes

Groups for whole address ranges, for devices without a clients entry.

FieldTypeDefaultAllowed values
prefixstringrequireda CIDR range such as 192.168.2.0/24 or fd00:abcd::/48; unique
groupstring"default"a declared group
priorityinteger100breaks a tie between equally specific ranges; lower wins

A clients entry beats any range, and a narrower range beats a wider one.

blocklist_sources

Blocklists nxdns downloads.

FieldTypeDefaultAllowed values
urlstringrequiredan http:// or https:// URL; unique
namestringrequirednot empty, at most 64 bytes
enabledbooltrue
is_suggestedboolfalsemarks the list as a suggestion in the web interface

A source blocks nothing until a group uses it through group_sources; nxdns check warns about one that no group uses.

nxdns reads hosts files, plain lists of domains, and Adblock Plus lists. From an Adblock Plus list it takes the rules that name a domain, ||example.com^, which block the domain and everything under it, and exception rules, @@||example.com^, which unblock a name that another list blocks. Your own rules always win over both.

It skips what does not translate into a DNS answer, and Protection → Sources counts it for each list:

  • Skipped regex: regex lines, which nxdns takes only from your own rules. Copy the ones you trust into a regex rule.
  • Skipped unsupported: page-element hiding (##), rules with $ options, and other browser-only syntax. A list with many of these and few domains is written for browser extensions; its DNS or hosts version blocks more.

group_sources

Which group uses which blocklist.

FieldTypeDefaultAllowed values
groupstringrequireda declared group
source_urlstringrequiredthe url of a declared blocklist source; each pair once

rules

Your own allow and block rules for a group. They are checked before any blocklist.

FieldTypeDefaultAllowed values
groupstringrequireda declared group
patternstringrequiredsee below
kind.exact, .wildcard or .regexrequired
action.allow or .blockrequired
  • .exact matches one name, such as ads.example.com.
  • .wildcard has one or more labels that are exactly *: *.example.com matches every name under example.com, but not example.com itself. A partial label such as ads*.example.com is not a wildcard; write a regex for that.
  • .regex is a regular expression matched against the lowercase name. It matches anywhere in the name unless you anchor it with ^ or $.

Exact rules are checked first, then wildcards, then regexes; within each kind, allow rules win over block rules.

A regex may use literal characters, ., classes such as [a-z0-9] and [^0-9], \d, \w, \ before punctuation to make it literal, *, +, ?, {n}, {n,m}, {n,}, |, ( … ), ^ and $. Anything else is refused with a message, rather than quietly read differently from what another engine would do: lookaround, backreferences, \s, \b, (?…), an empty alternative such as ads|, and a quantifier directly after another, such as a+?. A pattern is at most 256 bytes, and a group holds at most 256 regex rules. Matching takes time in proportion to the pattern and the name, so no pattern can slow resolution down.

local_records

Answers nxdns gives itself, without asking an upstream.

FieldTypeDefaultAllowed values
namestringrequireda domain name
rtype.a, .aaaa or .cnamerequired
valuestringrequiredan IPv4 address for .a, an IPv6 address for .aaaa, a domain name for .cname
ttlinteger3001–604800 s

Each name, type and value together appear once.

forward_zones

Domains that go to a resolver of your choice instead of the upstreams, such as your router’s local names.

FieldTypeDefaultAllowed values
zonestringrequireda domain name; unique
resolverstringrequiredudp://IP:port or tcp://IP:port, with an IP address and a port

The most specific zone wins.

.forward_zones = .{
.{ .zone = "lan", .resolver = "udp://192.168.1.1:53" },
.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" },
},

Passwords

web.password and web.password_hash are both optional, and leaving one out is not the same as setting it empty.

The file saysWhat happens to the stored password
Neither fieldNothing. It stays as it was.
.password = "…"Sets that password.
.password = ""Refused.
.password_hash = "$argon2id$…"Sets the password that hash belongs to, for example from an export.
.password_hash = ""Removes the password, which turns authentication off.
Both fieldsRefused.

The plaintext password is never stored: nxdns keeps an argon2id hash, and nxdns export writes .password = null with the hash. Applying the same file twice changes nothing.

Errors and warnings

nxdns check, nxdns import and a start in file mode print one line per problem, and report all of them, not just the first:

FAIL groups: no group named 'default'; every unknown client is assigned to it
FAIL rules[0].pattern: '(' is not a valid regex pattern

A FAIL stops the command with exit code 2. A WARN marks something allowed but probably unintended, such as a blocklist no group uses, and does not stop it.

URLs in these lines are shortened to their scheme, host and port, because the rest can hold an account identifier: a NextDNS URL such as https://dns.nextdns.io/abcd12 appears as https://dns.nextdns.io. A hostname is shown as it is, so prefer NextDNS’s form with the identifier in the path over abcd12.dns.nextdns.io.

A fuller example

.{
.dns = .{ .port = 53, .rate_limit = 1000 },
// Blocked names answer 0.0.0.0 or :: with a 5 second TTL.
.blocking = .{ .response = .zero, .ttl = 5 },
.web = .{ .password = "correct horse battery staple" },
// Serve DoT to the network.
.dot_server = .{
.enabled = true,
.cert_path = "/etc/nxdns/cert.pem",
.key_path = "/etc/nxdns/key.pem",
},
.logging = .{ .level = .warn, .retention_days = 14 },
.groups = .{
.{ .name = "default" },
.{ .name = "kids", .safe_search = true },
},
// Cloudflare first, Quad9 if it fails.
.upstreams = .{
.{ .url = "https://cloudflare-dns.com/dns-query", .priority = 10 },
.{ .url = "tls://9.9.9.9:853", .priority = 20, .tls_name = "dns.quad9.net" },
},
.clients = .{
.{ .ip = "192.168.1.20", .name = "Kids' tablet", .group = "kids" },
},
.client_prefixes = .{
.{ .prefix = "192.168.2.0/24", .group = "kids" },
},
.blocklist_sources = .{
.{ .url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", .name = "StevenBlack hosts" },
},
.group_sources = .{
.{ .group = "default", .source_url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" },
.{ .group = "kids", .source_url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" },
},
.rules = .{
.{ .group = "default", .pattern = "allowed.example.com", .kind = .exact, .action = .allow },
.{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
.{ .group = "kids", .pattern = "^ad[0-9]+-", .kind = .regex, .action = .block },
},
.local_records = .{
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
.{ .name = "files.lan", .rtype = .cname, .value = "nas.lan" },
},
// Names under lan, and reverse lookups, go to the router.
.forward_zones = .{
.{ .zone = "lan", .resolver = "udp://192.168.1.1:53" },
.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" },
},
}