36 KiB
Configuration reference
Every section, field and collection nxdns accepts, with its type, default, unit, validation rule and the subsystem that consumes it.
Source of truth: src/config/model.zig (the model and the defaults), src/config/validate.zig (the rules), src/config/{loader,reconcile,import,export}.zig (the lifecycle).
For how the file, the database and export/import relate to each other, see the configuration model. For the commands that read and write configuration, see the CLI reference.
File format
The file is ZON: a top-level anonymous struct whose fields are the sections and collections below. Enum values are ZON enum literals (.level = .err, .response = .nxdomain). Strings are double-quoted. The file may be at most 4 MiB (max_config_bytes in src/config/loader.zig); beyond that the error is ConfigTooLarge. A syntax error is reported with its line and column.
Absent fields keep their defaults, both in the file and in the database. A settings key stored in the database that the running binary does not know is warned about and ignored, never an error.
The logging.level = .err quirk
The log level error is a Zig keyword, so the ZON and model tag is .err while the database and the settings API store the operator-facing word "error". The file says .err; GET /api/settings says "error". "err" is not accepted as database text, and .error is not a ZON tag.
What is not in the file
Storage paths are process arguments, not configuration:
| Argument | Default | Meaning |
|---|---|---|
--data-dir DIR |
/var/lib/nxdns |
Holds config.db and querylog.db; see files and directories. |
--config FILE |
— | On run, makes FILE the sole source of configuration; on check, grades FILE instead of the database. No default: without it the database is the configuration. |
--admin-dev DIR |
— | run only; serves the web interface from a directory instead of the embedded assets. |
Scalar sections
The "Key" column is the settings key as stored in the database (section.field); in the file the same field lives inside its section block, for example .dns = .{ .port = 53 }.
upstream
Timeouts for talking to upstream resolvers.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
upstream.attempt_timeout_ms |
u32 | 2500 | ms | 100–120000, and not above total_timeout_ms |
deadline on one attempt against one upstream inside the pool's failover loop (src/upstream/pool.zig), the whole attempt including the connect |
upstream.read_timeout_ms |
u32 | 3000 | ms | 100–120000 | read deadline on conditional-forward-zone exchanges (src/local/forward_client.zig) |
upstream.total_timeout_ms |
u32 | 5000 | ms | 100–120000 | per-query budget of the upstream pool (src/upstream/pool.zig): every failover attempt together, not one of them; also the nxdns check probe deadline |
The two pool budgets nest. attempt_timeout_ms bounds one try against one upstream; when it expires the pool records the failure and moves to the next candidate. total_timeout_ms bounds the whole loop, so a query against five unreachable upstreams costs the total budget once, not five attempt budgets in a row. When the total expires the in-flight attempt is canceled and the query fails with a timeout.
read_timeout_ms is unrelated to both. It bounds a different subsystem — the conditional-forward-zone client — so no cross-check relates it to the pool's budgets, and it is free to sit above either of them.
dns
The plain DNS listener (UDP and TCP).
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
dns.bind_ipv4 |
string | "0.0.0.0" |
IP address | must parse as an IPv4 literal | UDP/TCP listener bind (src/app.zig) |
dns.bind_ipv6 |
string | "::" |
IP address | must parse as an IPv6 literal | UDP/TCP listener bind (src/app.zig) |
dns.port |
u16 | 53 | port | 1–65535 (0 is refused) | UDP/TCP listener port |
dns.rate_limit |
u32 | 1000 | queries per window | at least 1 | per-client DNS rate limiter (src/server/rate_limiter.zig) |
dns.rate_window_seconds |
u32 | 60 | seconds | 1–3600 | window of the same limiter |
dns.bind_ipv4 and dns.bind_ipv6 each name one socket of the dual-stack pair, so each is required to be a literal of its own family. An IPv4 wildcard in dns.bind_ipv6 is refused: it would bind IPv4 as the "v6" socket and make the real IPv4 bind fail with AddressInUse, silently removing the IPv6 service.
blocking
What a blocked query gets back.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
blocking.response |
enum .zero | .nxdomain |
.zero |
— | one of the two tags | blocked-response synthesis (src/filter/response.zig): .zero answers 0.0.0.0 / ::, .nxdomain answers NXDOMAIN |
blocking.ttl |
u32 | 5 | seconds | at most 86400; 0 allowed | TTL on the synthesized block answer |
cache
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
cache.size |
u32 | 10000 | entries | 1–1000000 | DNS answer cache capacity (src/cache/dns_cache.zig) |
cache.negative_ttl_max |
u32 | 3600 | seconds | at most 86400 | cap on cached negative answers; 0 disables negative caching |
The cache's slot array is allocated in full at startup, so cache.size carries a ceiling: it is a sanity bound against a typo, not a promise that the value fits in the box's memory. There is no "off" value — to run without a cache, set cache.size to 1.
web
The web interface and REST API.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
web.enabled |
bool | true | — | — | gates the whole web stack: server, sessions, SSE hub, API limiter (src/app.zig) |
web.bind |
string | "0.0.0.0" |
IP address | must parse as an IP address of either family | web listener bind (src/web/server.zig) |
web.port |
u16 | 8080 | port | 1–65535 (0 is refused) | web listener port |
web.password |
optional string | absent | — | must not be set together with web.password_hash; the empty string is refused |
operator input only; hashed and discarded. Never a settings row — see Password and hash |
web.password_hash |
optional string | absent | — | — | argon2id PHC string verified at login (src/web/auth.zig); "" disables authentication, absent keeps the stored hash |
web.session_ttl_hours |
u16 | 24 | hours | at least 1 | session expiry and cookie Max-Age (src/web/auth.zig) |
web.api_rate_limit_per_min |
u32 | 300 | requests per minute | at least 1 | API token-bucket limiter (src/web/api_limiter.zig) |
web.api_localhost_exempt |
bool | true | — | — | loopback requests skip the API limiter |
web.sse_max_connections_per_ip |
u16 | 3 | connections | at least 1 | cap on concurrent SSE streams per client address |
web.trusted_proxies |
string | "" |
— | comma-separated IP literals | reverse proxies whose X-Forwarded-For nxdns believes (src/web/server.zig); empty trusts none |
doh_server
The DNS-over-HTTPS listener (server side, for clients on the LAN). See enable DoH and DoT.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
doh_server.enabled |
bool | false | — | — | gates the DoH listener (src/server/doh_server.zig) |
doh_server.bind |
string | "0.0.0.0" |
IP address | must parse as an IP address of either family | DoH listener bind |
doh_server.port |
u16 | 443 | port | 1–65535 (0 is refused, enabled or not) | DoH listener port |
doh_server.cert_path |
string | "/etc/nxdns/cert.pem" |
path | non-empty when enabled | certificate loaded into the hot-reloading CertStore; the file itself is loaded and paired with the key by nxdns check, not by the validator |
doh_server.key_path |
string | "/etc/nxdns/key.pem" |
path | non-empty when enabled | private key for the same; nxdns check warns when it is readable beyond its owner |
dot_server
The DNS-over-TLS listener. Same shape as doh_server; only the default port differs.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
dot_server.enabled |
bool | false | — | — | gates the DoT listener (src/server/dot_server.zig) |
dot_server.bind |
string | "0.0.0.0" |
IP address | must parse as an IP address of either family | DoT listener bind |
dot_server.port |
u16 | 853 | port | 1–65535 (0 is refused, enabled or not) | DoT listener port |
dot_server.cert_path |
string | "/etc/nxdns/cert.pem" |
path | non-empty when enabled | certificate, shared CertStore with hot reload |
dot_server.key_path |
string | "/etc/nxdns/key.pem" |
path | non-empty when enabled | private key for the same |
edns
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
edns.ecs_mode |
enum .strip | .forward |
.strip |
— | one of the two tags | EDNS Client Subnet handling in the query path (src/server/handler.zig, src/dns/edns.zig): .strip removes the client subnet before forwarding, .forward passes it through |
logging
Process log and query log behavior.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
logging.level |
enum .err | .warn | .info | .debug |
.info |
— | one of the four tags; stored as "error" / "warn" / "info" / "debug" |
log threshold (src/platform/logging.zig) |
logging.retention_days |
u16 | 30 | days | at least 1 | query-log pruning cutoff (src/storage/retention.zig) and the client tracker's last-seen cutoff (src/server/clients.zig) |
logging.query_log_buffer_max |
u32 | 10000 | entries | 1–37449 | in-memory query-log ring size and backpressure cap (src/storage/logger.zig); the ceiling is derived at compile time from @sizeOf(logger.Entry) so the queue's worst case stays within 64 MiB, and it moves whenever the entry's width does |
logging.query_log_flush_interval_s |
u16 | 60 | seconds | 0–3600 | how long the query-log writer gathers entries before committing them in one transaction (src/storage/logger.zig); see the note below |
logging.hide_domains |
bool | false | — | — | the query log stores a hidden marker instead of the domain |
logging.hide_client_ips |
bool | false | — | — | the query log stores a hidden marker instead of the client address |
logging.output |
enum .stderr | .syslog | .file |
.stderr |
— | one of the three tags | log sink selection (src/platform/logging.zig); .stderr and .syslog both write to stderr (journald captures it), .file rotates |
logging.file_path |
string | "/var/log/nxdns/nxdns.log" |
path | when output is .file: non-empty and starting with / |
rotating log file; its directory also feeds the disk monitor. The binary does not create the directory |
logging.max_size_mb |
u32 | 50 | MiB | at least 1 | rotation trigger for the log file |
logging.max_files |
u8 | 5 | files | at least 1 | log files kept in total, the live one included, so the highest rotated generation is max_files - 1; the default 5 keeps nxdns.log plus nxdns.log.1 through nxdns.log.4, and a value of 1 keeps only the live file, which rotation deletes rather than renames |
What query_log_flush_interval_s costs and buys
The query-log writer commits one transaction per interval instead of one per query. At a household's query rate — a few queries a second at most, often a fraction of one — a per-query commit writes hundreds of times more bytes to the disk than the rows themselves occupy, because every commit rewrites the WAL frames, the WAL index and the page headers whatever the row size. That write amplification is what wears out an SD card, and the default of 60 seconds is what stops it. It matches Pi-hole's DBinterval, which defaults to the same minute for the same reason.
What it costs:
- Crash-loss window. A process that dies takes roughly
intervalseconds of query history with it. That is the normal case, not a guaranteed maximum: a batch the disk monitor is holding back (free space below the critical threshold) or one waiting on a database write lock can be considerably older when the process dies. Power loss can additionally lose recent committed transactions, becausequerylog.dbruns with WAL andsynchronous=NORMAL— that was already true at any interval, and setting0does not buy per-query durability. Query history is the least valuable data on this box: nothing else depends on it, and it is deleted by retention anyway. - Staleness. Every read backed by the query log — the query-log page, the Overview totals, the timeseries — lags about
intervalseconds behind, and further behind while writes are gated or slow. The live view does not lag: it is fed from the SSE hub before the queue, so queries appear there the moment they are answered.
0 means "do not wait": the writer commits the entry that woke it together with whatever is already queued, up to 100 rows. Use it when you want the query-log page to be current to the second and you do not care what that costs the disk.
Two things do not change with the interval: a batch is capped at 100 rows, so a burst is committed as soon as it fills one rather than waiting out the window, and shutdown writes what the writer is holding instead of waiting for the interval to end.
disk
Free-space thresholds for the data directory. Below them the query-log writer, the client tracker and the blocklist scheduler are throttled (src/storage/disk_monitor.zig); DNS resolution is never gated.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
disk.min_free_mb |
u32 | 200 | MiB | at least 1, and not above warn_free_mb |
critical threshold |
disk.warn_free_mb |
u32 | 500 | MiB | at least 1 | warn threshold |
blocklist_update
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
blocklist_update.enabled |
bool | true | — | — | blocklist refresh scheduler (src/filter/manager.zig); when false the scheduler stops after the startup pass and only a manual refresh runs |
blocklist_update.interval_hours |
u16 | 24 | hours | at least 1 | sleep between refresh passes and the per-source staleness test; each pass is preceded by the orphan sweep, which runs even when the pass itself is skipped for disk space |
Collections
Collections are ZON lists of structs. Fields without a default are required. Runtime columns (first/last seen timestamps, per-source download counters) are deliberately not part of the model: import sets timestamps to the import time and export omits them, which is what keeps the round trip byte-stable.
groups
Client groups. A group named default is required; every client not assigned elsewhere lands in it, and import guarantees it keeps database id 1.
| Field | Type | Default | Validation |
|---|---|---|---|
name |
string | required | non-empty, unique |
safe_search |
bool | false | — |
Consumed by the filter engine (src/filter/matcher.zig); safe_search triggers the safe-search rewrite in the query path.
upstreams
Upstream resolvers. At least one enabled upstream is required.
| Field | Type | Default | Validation |
|---|---|---|---|
url |
string | required | an https:// (DoH) or tls:// (DoT) endpoint accepted by transport.Endpoint.parse; unique; a tls:// host must be an IP literal |
priority |
i32 | 100 | — (lower is tried first) |
enabled |
bool | true | — |
tls_name |
string | "" |
DoT only — a tls_name on an https:// upstream is an error; when set it must be a valid domain name |
Consumed by the upstream pool (src/upstream/pool.zig): entries are sorted by ascending priority and tried in order with failover. tls_name sets SNI and the certificate verification name for a tls:// upstream written as an IP literal; empty means "verify by the URL host" (src/upstream/dot_client.zig).
A tls:// upstream's host must be an IP literal. nxdns does not resolve an upstream's own name — that is a bootstrap problem, and the DoT client refuses a non-literal host on every dial — so the validator rejects the hostname form rather than letting it fail at query time as an unreachable upstream. Write the address and put the name in tls_name:
.{ .url = "tls://9.9.9.9:853", .tls_name = "dns.quad9.net" },
A DoH upstream is not affected: https:// goes through the HTTP client, which resolves normally, so a hostname there is the usual form.
clients
Known clients with a fixed group assignment.
| Field | Type | Default | Validation |
|---|---|---|---|
ip |
string | required | an IP address; unique after canonicalization (FD00::1 and fd00:0:0:0:0:0:0:1 collide) |
name |
string | "" |
— (display only, never read by the resolver) |
group |
string | "default" |
must name a declared group |
Consumed by the filter engine's exact address-to-group lookup (src/filter/matcher.zig).
Learned names
A client row that carries no name of its own can still show one. Every flush pass the client tracker takes at most 16 unnamed rows that are due, builds each address's reverse name (192.168.1.10 becomes 10.1.168.192.in-addr.arpa), and matches it against the declared forward_zones. On a match it sends one PTR query to that zone's resolver and stores the answer as the row's learned name. A row becomes due again 24 hours after the resolver answered or returned NXDOMAIN, and 1 hour after any other outcome — no covering zone, a transport failure, or a reply nxdns rejected — so declaring the missing zone or fixing the resolver shows names within the hour. This requires a conditional forward zone that covers the LAN's reverse space — for example 168.192.in-addr.arpa pointed at the router. Without such a zone nxdns sends the reverse name to nobody, and the row stays unnamed.
The PTR query goes to the resolver of the declared zone, wherever the operator pointed it; nxdns does not second-guess that declaration, and it cannot stop a LAN resolver from forwarding the query onward.
A learned name is runtime state, like last_seen:
- A hand-typed
namealways wins, and a named row is never asked about again. - Learned names never appear in
nxdns export, andnxdns importnever sets one. - Each row refreshes once a day. A device rename or a DHCP lease change can therefore display a stale name for up to 24 hours, until the next refresh overwrites it or the router reports no name and nxdns clears it.
client_prefixes
Group assignment by CIDR prefix, for clients without an exact entry.
| Field | Type | Default | Validation |
|---|---|---|---|
prefix |
string | required | a CIDR prefix (192.168.2.0/24, fd00:abcd::/48); unique after canonicalization |
group |
string | "default" |
must name a declared group |
priority |
i32 | 100 | — (ties on match are broken by lower priority) |
Consumed by the filter engine's longest-prefix match (src/filter/matcher.zig).
blocklist_sources
Downloadable blocklists.
| Field | Type | Default | Validation |
|---|---|---|---|
url |
string | required | an http:// or https:// URL with a host; unique |
name |
string | required | non-empty |
enabled |
bool | true | — |
is_suggested |
bool | false | — (web UI hint only, never read by the resolver) |
Consumed by the blocklist manager (src/filter/manager.zig): downloaded by the fetcher and compiled into domain sets. A disabled source is neither downloaded nor loaded.
A list in Adblock Plus syntax may also carry exception lines, @@||name^ and @@||name, either of which may end in $important. Those become allow entries that cancel what any attached list blocks, for the name and its subdomains. They cancel nothing an operator decided: every rule of the table above is checked first, so a downloaded list can reopen only a hole another downloaded list dug. Each source reports how many it carried as exceptions; there is no way to write one by hand, and no reason to want one — write an allow rule instead.
Two counters report what a compile skipped, and they are different facts. skipped_regex counts regex lines: nxdns has a regex engine, but it takes patterns only from the operator, so a regex line in a downloaded list is counted, skipped and surfaced — adopt the ones you trust as regex rules. skipped_unsupported counts lines nxdns cannot safely translate into a DNS decision: cosmetic element hiding (##, #@#, #?#), rules carrying a $ modifier (except $important on an exception line, tolerated above), scheme anchors, non-anchored @@ forms — and, in a domains-format list, a line holding more than one field before its inline comment, which usually means the list is really a hosts file that was declared as domains. Neither is an error, and the two are never one number. A large skipped_unsupported beside a small domain_count usually means the list is written for browser extensions, and its DNS or hosts variant will block more here. Both appear per source in the blocklists UI and on /api/blocklists.
group_sources
Which groups consult which blocklist sources.
| Field | Type | Default | Validation |
|---|---|---|---|
group |
string | required | must name a declared group |
source_url |
string | required | must name a declared blocklist source's url; the (group, source_url) pair is unique |
Consumed by the filter engine when assembling each group's compiled domain sets (src/filter/matcher.zig). A link to a disabled source is skipped.
rules
Per-group allow and block overrides, checked before the blocklists.
| Field | Type | Default | Validation |
|---|---|---|---|
group |
string | required | must name a declared group |
pattern |
string | required | see below |
kind |
enum .exact | .wildcard | .regex |
required | — |
action |
enum .allow | .block |
required | — |
Pattern rules: an .exact pattern is a plain domain name and may not contain *. A .wildcard pattern must contain at least one label that is exactly * (*.tracker.example, or * alone), and every other label must be a legal DNS label. ads*.example is not a valid wildcard; a partial label is what the .regex kind is for.
A .regex pattern is a regular expression matched against the whole normalized lowercase name, unanchored unless you write ^ or $ — the POSIX-grep convention. It is stored exactly as you typed it, which the other two kinds are not: lowercasing would turn \D into \d, and trimming a trailing . would delete an any-byte atom. The engine (src/filter/regex.zig) accepts literal bytes, . for any byte, character classes [a-z0-9] with a leading ^ for negation, the escapes \d and \w plus \ before any other ASCII punctuation to make it a literal, the repetitions * + ? {n} {n,m} {n,}, alternation |, grouping (...), and the anchors ^ and $.
Everything else is refused at the edge rather than approximated, so a pattern written for another engine fails where you can read the diagnostic instead of silently matching names you did not mean:
- backreferences, lookaround, captures, named groups, Unicode classes and the
(?…)prefix they share; - any alphanumeric escape the list above omits —
\s,\b,\1,\D; - a
]inside a class, unless written\]; - an empty pattern, and an empty branch:
ads|is refused rather than read as a pattern that matches every name; - a quantifier applied straight to another quantifier:
a+?is refused rather than read as(a+)?, which matches every name. Write(a+)?to mean that.
A pattern is at most 256 bytes and compiles to at most 1024 instructions, each limit with its own diagnostic, and one group holds at most 256 regex rules. Groups do not capture, and the engine simulates every alternative in lockstep, so a pattern costs at most its compiled length times the length of the name — (a+)+b is as cheap here as it is expensive in a backtracking engine.
Consumed by the filter engine's rule sets (src/filter/rules.zig), which checks the three kinds in the order they are listed above, allow before block within each. Regex is checked last of the three because it is the only kind that costs more than a hash lookup or a label walk.
local_records
Local DNS answers, served without touching any upstream.
| Field | Type | Default | Validation |
|---|---|---|---|
name |
string | required | a valid domain name |
rtype |
enum .a | .aaaa | .cname |
required | stored as A / AAAA / CNAME |
value |
string | required | an IPv4 address for .a, an IPv6 address for .aaaa, a domain name for .cname |
ttl |
u32 | 300 | 1–604800 seconds |
The (name, rtype, value) triple is unique. Consumed by the local records table in the query path (src/local/records.zig).
forward_zones
Zones resolved by a specific resolver instead of the configured upstreams, for LAN or corporate domains.
| Field | Type | Default | Validation |
|---|---|---|---|
zone |
string | required | a valid domain name; unique |
resolver |
string | required | udp://IP:port or tcp://IP:port; the host must be an IP literal and the port is mandatory |
The resolver host must be an IP literal because resolving the resolver's own name would be a bootstrap problem. Matching is longest suffix (src/local/forward_zones.zig); the exchange is UDP then TCP (src/local/forward_client.zig) with upstream.read_timeout_ms as the read deadline.
Reverse zones are declared the same way, and one is the prerequisite for learned client names:
.forward_zones = .{
.{ .zone = "lan", .resolver = "udp://192.168.1.1:53" },
.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" },
},
Password and hash
Both fields are optional, and the difference between absent and empty is the whole design. Absent means "keep whatever is stored". Empty means "there is no password".
| The file says | What happens to the stored hash |
|---|---|
| Neither field | Untouched. Authentication stays exactly as it was. |
.password = "some-password" |
Verified against the stored hash; kept when it matches, replaced with a fresh hash when it does not. |
.password = "" |
Refused, with a diagnostic naming the remedy. |
.password_hash = "$argon2id$…" |
Written verbatim. |
.password_hash = "" |
Cleared, which disables authentication. |
| Both fields | Refused (PasswordAndHashBothSet — ambiguity in a security setting). |
Silence has to mean "keep", because the alternative is a trap. An operator who exports a configuration and trims the long PHC string out of it before committing the file to git means "leave the password alone", not "open the admin interface to the LAN". So disabling authentication takes the explicit empty string, and the empty plaintext — which would otherwise hash into a real hash that no login can ever satisfy — is refused outright:
FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication
web.passwordis operator input only. It is hashed with argon2id (OWASP parameters: t=2, m=19 MiB, p=1, PHC encoding) intoweb.password_hashand discarded. There is noweb.passwordsettings row, andnxdns exportalways writes.password = null.web.password_hashis the stored argon2id PHC string. Supplying it directly, for example from a previous export, is how a backup restores authentication without knowing the password.
A plaintext password that has not changed is verified rather than re-hashed, so applying the same file twice leaves the same bytes in the database. That is what keeps the export/import round trip byte-stable with a password in the file. It costs a full argon2id computation either way — the verification is not a shortcut, and caching the plaintext to skip it would be a security bug.
Any change to whether a password is set is announced at startup, never left as a count:
web authentication is now enabled
web authentication is now disabled
See set up admin authentication.
Validation errors
nxdns check, nxdns import and a nxdns run --config that reads the file all print one FAIL path: message line per problem, and report every problem rather than the first. A finding that is legal but almost certainly unintended is prefixed WARN instead: it does not change the exit code, and it is printed by all three even when nothing failed, so an accepted configuration still says what is odd about it.
A url in a diagnostic is redacted to its scheme, host and port. The userinfo, the path, the query and the fragment are dropped, and control characters are escaped. These lines reach the journal, and every one of those parts can carry a credential: a NextDNS DoH upstream is https://dns.nextdns.io/abcd12, where the path segment is the whole account identifier. The field path beside the message names the entry, so blocklist_sources[1].url still says which one to go and fix.
What redaction cannot remove is the host, because a hostname is not a secret in the general case — it is resolved publicly and offered as SNI on every connection — and dropping it would leave a diagnostic that names nothing worth reading. A vendor that puts an account identifier in the hostname therefore has that identifier appear in any line naming the entry. NextDNS is the example: abcd12.dns.nextdns.io is a profile id.
Two things limit the exposure. A hostname tls:// upstream is rejected by the validator (see upstreams above), so the DoT form of that URL can only reach the log once, in the line rejecting it — never on every failover. And NextDNS publishes a DoH endpoint, https://dns.nextdns.io/abcd12, whose identifier sits in the path and is redacted in full; configure that form and nothing identifying reaches the log at all.
The error set is validate.ValidateError in src/config/validate.zig:
| Error | Raised by |
|---|---|
NoUpstreams |
no upstream has enabled = true |
BadUpstreamUrl |
an upstream url is not an https:// or tls:// endpoint |
UpstreamHostNotIpLiteral |
a tls:// upstream url names a host instead of an IP literal |
DuplicateUpstreamUrl |
two upstreams share a url |
BadTlsName |
a tls_name is not a valid domain name |
TlsNameOnNonTlsUpstream |
a tls_name on a non-tls:// upstream |
MissingDefaultGroup |
no group is named default |
DuplicateGroupName |
two groups share a name |
EmptyGroupName |
a group name is empty |
GroupNameTooLong |
a group name is longer than 64 bytes; it is copied into every logged query |
UnknownGroup |
a client, prefix, group source or rule names a group that is not declared |
BadClientIp / DuplicateClientIp |
a client ip is unparseable, or collides after canonicalization |
BadClientPrefix / DuplicateClientPrefix |
the same for a client_prefixes.prefix |
BadSourceUrl / DuplicateSourceUrl / EmptySourceName |
blocklist source fields |
SourceNameTooLong |
a blocklist source name is longer than 64 bytes; it is copied into every logged query |
UnknownSource / DuplicateGroupSource |
group_sources links |
BadRulePattern |
a rule pattern does not match its kind |
BadLocalRecordName / BadLocalRecordValue / DuplicateLocalRecord |
local record fields |
BadForwardZone / DuplicateForwardZone / BadResolverUrl |
forward zone fields |
BadPort |
a port field is 0 |
BadTimeout |
a timeout is outside 100–120000 ms, or attempt is above total |
BadTtl |
blocking.ttl, cache.negative_ttl_max, a record ttl, web.session_ttl_hours or blocklist_update.interval_hours outside its range |
BadCacheSize |
cache.size outside 1–1000000 |
BadRetention |
logging.retention_days below 1, or logging.query_log_buffer_max outside 1–37449 |
BadFlushInterval |
logging.query_log_flush_interval_s above 3600 |
BadLogRotation |
logging.max_size_mb or logging.max_files below 1 |
BadDiskThresholds |
a threshold below 1, or min_free_mb above warn_free_mb |
BadRateLimit |
dns.rate_limit, dns.rate_window_seconds, web.api_rate_limit_per_min or web.sse_max_connections_per_ip out of range |
BadBindAddress |
a bind field is not an IP address, or not of the required family |
BadTrustedProxy |
an element of web.trusted_proxies is not an IP literal |
MissingCertPath / MissingKeyPath |
an enabled TLS listener with an empty path |
MissingLogPath |
logging.output = .file with an empty or relative file_path |
PasswordAndHashBothSet |
both web.password and web.password_hash are set |
Warnings are a separate set, outside ValidateError because they are not failures. There is one:
| Warning | Raised by |
|---|---|
SourceInNoGroup |
a blocklist_sources entry that no group_sources link names; it downloads and blocks nothing until a group uses it |
Minimal working example
The smallest file that passes validation: a default group and one enabled upstream. Everything else keeps its default.
.{
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://dns.quad9.net/dns-query" } },
}
Fuller annotated example
.{
// Plain DNS on the standard port, rate-limited per client.
.dns = .{
.bind_ipv4 = "0.0.0.0",
.bind_ipv6 = "::",
.port = 53,
.rate_limit = 1000,
.rate_window_seconds = 60,
},
// Blocked queries answer 0.0.0.0 / :: with a 5 second TTL.
.blocking = .{ .response = .zero, .ttl = 5 },
.cache = .{ .size = 10000, .negative_ttl_max = 3600 },
// Web UI on 8080. The password is hashed and never stored as plaintext;
// leave .password_hash out when setting .password (they are exclusive).
.web = .{
.enabled = true,
.port = 8080,
.password = "correct horse battery staple",
.session_ttl_hours = 24,
},
// Serve DoT to the LAN. The key file should be mode 0600.
.dot_server = .{
.enabled = true,
.port = 853,
.cert_path = "/etc/nxdns/cert.pem",
.key_path = "/etc/nxdns/key.pem",
},
// Strip EDNS Client Subnet before forwarding (the default).
.edns = .{ .ecs_mode = .strip },
// ".err" in the file; the settings API shows it as "error".
.logging = .{ .level = .err, .retention_days = 14 },
.blocklist_update = .{ .enabled = true, .interval_hours = 24 },
// "default" is mandatory. Additional groups get their own rules,
// blocklists and safe-search flag.
.groups = .{
.{ .name = "default" },
.{ .name = "kids", .safe_search = true },
},
// Lower priority is tried first; the second entry is a failover.
// tls_name is needed when a tls:// upstream is written as an IP
// literal, so certificate verification has a DNS name to match.
.upstreams = .{
.{ .url = "https://dns.quad9.net/dns-query", .priority = 10 },
.{ .url = "tls://9.9.9.9:853", .priority = 20, .tls_name = "dns.quad9.net" },
},
// Exact client assignments win over prefixes.
.clients = .{
.{ .ip = "192.168.1.20", .name = "tablet", .group = "kids" },
},
.client_prefixes = .{
.{ .prefix = "192.168.2.0/24", .group = "kids" },
},
.blocklist_sources = .{
.{ .url = "https://lists.example/ads.txt", .name = "ads" },
},
.group_sources = .{
.{ .group = "kids", .source_url = "https://lists.example/ads.txt" },
},
// Overrides beat blocklists. Wildcards need a label that is exactly "*";
// a partial label takes a regex, which is unanchored unless you say "^".
.rules = .{
.{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
.{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
.{ .group = "kids", .pattern = "^ad[0-9]+-", .kind = .regex, .action = .block },
},
// Local names, answered without any upstream.
.local_records = .{
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
.{ .name = "www.lan", .rtype = .cname, .value = "nas.lan" },
},
// Everything under corp.lan goes to the LAN resolver directly.
.forward_zones = .{
.{ .zone = "corp.lan", .resolver = "udp://192.168.1.1:53" },
},
}