20 KiB
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.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
upstream.attempt_timeout_ms |
integer | 2500 | 100–120000 ms, and not above total_timeout_ms |
Time allowed for one try against one upstream, connecting included. When it runs out, nxdns tries the next upstream. |
upstream.total_timeout_ms |
integer | 5000 | 100–120000 ms | Time 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_ms |
integer | 3000 | 100–120000 ms | Time 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.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
dns.bind_ipv4 |
string | "0.0.0.0" |
an IPv4 address | The IPv4 address to listen on. |
dns.bind_ipv6 |
string | "::" |
an IPv6 address | The IPv6 address to listen on. |
dns.port |
integer | 53 | 1–65535 | The port to listen on. |
dns.rate_limit |
integer | 1000 | at least 1 | How many queries one client may send per window. |
dns.rate_window_seconds |
integer | 60 | 1–3600 s | The 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.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
blocking.response |
.zero or .nxdomain |
.zero |
.zero answers 0.0.0.0 or ::; .nxdomain answers that the name does not exist. |
|
blocking.ttl |
integer | 5 | 0–86400 s | How long a client may cache a blocked answer. |
cache
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
cache.size |
integer | 10000 | 1–1000000 entries | How many answers nxdns caches. The memory is reserved at startup. |
cache.negative_ttl_max |
integer | 3600 | 0–86400 s | The 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.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
web.enabled |
bool | true | Runs the web interface. | |
web.bind |
string | "0.0.0.0" |
an IP address | The address it listens on. |
web.port |
integer | 8080 | 1–65535 | The port it listens on. |
web.password |
string | none | not empty; not together with password_hash |
Sets the admin password. Only its hash is stored; see Passwords. |
web.password_hash |
string | none | an argon2id hash, or "" |
The stored password hash. "" removes the password. |
web.session_ttl_hours |
integer | 24 | at least 1 | How long a login lasts. |
web.api_rate_limit_per_min |
integer | 300 | at least 1 | How many requests one client may make to the web interface per minute. |
web.api_localhost_exempt |
bool | true | Requests from the nxdns machine itself skip that limit. | |
web.sse_max_connections_per_ip |
integer | 3 | at least 1 | How many live views (such as Activity's Live tab) one client may keep open. |
web.trusted_proxies |
string | "" |
IP addresses separated by commas | Reverse 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.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
doh_server.enabled |
bool | false | Runs the DoH listener. | |
doh_server.bind |
string | "0.0.0.0" |
an IP address | The address it listens on. |
doh_server.port |
integer | 443 | 1–65535 | The port it listens on. |
doh_server.cert_path |
string | "/etc/nxdns/cert.pem" |
not empty when enabled | The certificate, as PEM. A renewed file is picked up within 30 seconds. |
doh_server.key_path |
string | "/etc/nxdns/key.pem" |
not empty when enabled | Its private key, as PEM. nxdns check warns when others can read it. |
dot_server
DNS over TLS for clients on your network.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
dot_server.enabled |
bool | false | Runs the DoT listener. | |
dot_server.bind |
string | "0.0.0.0" |
an IP address | The address it listens on. |
dot_server.port |
integer | 853 | 1–65535 | The port it listens on. |
dot_server.cert_path |
string | "/etc/nxdns/cert.pem" |
not empty when enabled | The certificate, as PEM. |
dot_server.key_path |
string | "/etc/nxdns/key.pem" |
not empty when enabled | Its private key, as PEM. |
edns
| Key | Type | Default | Allowed values | What 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.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
logging.level |
.err, .warn, .info or .debug |
.info |
The least important log line nxdns writes. The file says .err; the web interface says error. |
|
logging.retention_days |
integer | 30 | at least 1 | How long the query history is kept, and how long a device that stopped asking stays in Clients. |
logging.query_log_buffer_max |
integer | 10000 | 1–37449 | How many queries nxdns holds in memory while it waits to write them. |
logging.query_log_flush_interval_s |
integer | 60 | 0–3600 s | How often the query history is written to disk; see below. |
logging.hide_domains |
bool | false | Stores a placeholder instead of the name in the query history. | |
logging.hide_client_ips |
bool | false | Stores a placeholder instead of the client's address in the query history. | |
logging.output |
.stderr, .syslog or .file |
.stderr |
Where 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_path |
string | "/var/log/nxdns/nxdns.log" |
an absolute path, when output is .file |
The log file. nxdns does not create its directory. |
logging.max_size_mb |
integer | 50 | at least 1 | The size at which the log file is rotated. |
logging.max_files |
integer | 5 | at least 1 | How 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.
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
disk.min_free_mb |
integer | 200 | at least 1, and not above warn_free_mb |
Below this, nxdns stops writing query history and device records and stops downloading blocklists, until space comes back. |
disk.warn_free_mb |
integer | 500 | at least 1 | Below this, nxdns logs a warning. |
blocklist_update
| Key | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
blocklist_update.enabled |
bool | true | Refreshes blocklists on a schedule. When false, they refresh only at startup and when you press Update now. | |
blocklist_update.interval_hours |
integer | 24 | at least 1 | How 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.
| Field | Type | Default | Allowed values |
|---|---|---|---|
name |
string | required | not empty, at most 64 bytes, unique |
safe_search |
bool | false |
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.
| Field | Type | Default | Allowed values |
|---|---|---|---|
url |
string | required | an https:// or tls:// URL; unique |
priority |
integer | 100 | lower is tried first |
enabled |
bool | true | |
tls_name |
string | "" |
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.
| Field | Type | Default | Allowed values |
|---|---|---|---|
ip |
string | required | an IP address; unique |
name |
string | "" |
the name shown in the web interface |
group |
string | "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.
| Field | Type | Default | Allowed values |
|---|---|---|---|
prefix |
string | required | a CIDR range such as 192.168.2.0/24 or fd00:abcd::/48; unique |
group |
string | "default" |
a declared group |
priority |
integer | 100 | breaks 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.
| Field | Type | Default | Allowed values |
|---|---|---|---|
url |
string | required | an http:// or https:// URL; unique |
name |
string | required | not empty, at most 64 bytes |
enabled |
bool | true | |
is_suggested |
bool | false | marks 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
regexrule. - 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.
| Field | Type | Default | Allowed values |
|---|---|---|---|
group |
string | required | a declared group |
source_url |
string | required | the 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.
| Field | Type | Default | Allowed values |
|---|---|---|---|
group |
string | required | a declared group |
pattern |
string | required | see below |
kind |
.exact, .wildcard or .regex |
required | |
action |
.allow or .block |
required |
.exactmatches one name, such asads.example.com..wildcardhas one or more labels that are exactly*:*.example.commatches every name underexample.com, but notexample.comitself. A partial label such asads*.example.comis not a wildcard; write a regex for that..regexis 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.
| Field | Type | Default | Allowed values |
|---|---|---|---|
name |
string | required | a domain name |
rtype |
.a, .aaaa or .cname |
required | |
value |
string | required | an IPv4 address for .a, an IPv6 address for .aaaa, a domain name for .cname |
ttl |
integer | 300 | 1–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.
| Field | Type | Default | Allowed values |
|---|---|---|---|
zone |
string | required | a domain name; unique |
resolver |
string | required | udp://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 says | What happens to the stored password |
|---|---|
| Neither field | Nothing. 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 fields | Refused. |
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" },
},
}