22 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/{bootstrap,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/import.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 |
/etc/nxdns/config.zon |
Names the seed file. |
--web-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.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, and not below read_timeout_ms |
per-query budget of the upstream pool (src/upstream/pool.zig), the whole attempt including the connect; also the nxdns check probe deadline |
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 | none; 0 disables caching (puts short-circuit) | 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 |
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 |
string | "" |
— | must not be set together with web.password_hash |
operator input only; hashed at import and discarded. Never a settings row — see Password and hash |
web.password_hash |
string | "" |
— | — | argon2id PHC string verified at login (src/web/auth.zig); "" disables authentication |
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 |
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; readability is checked 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 | at least 1 | in-memory query-log ring size and backpressure cap (src/storage/logger.zig) |
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 |
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 |
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 |
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).
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).
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.
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 |
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.
Consumed by the filter engine's rule sets (src/filter/rules.zig).
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.
Password and hash
Exactly one of web.password and web.password_hash may be set; setting both
is refused (PasswordAndHashBothSet — ambiguity in a security setting).
web.passwordis operator input only. At import time 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 = "".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.- Both empty disables web authentication entirely.
Because the export carries the hash and re-importing an exported file takes the "password is empty" branch, the export/import round trip preserves the hash byte for byte. See set up admin authentication.
Validation errors
nxdns check and nxdns import print one path: message line per problem and
report every problem, not just the first. 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 |
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 |
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 |
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 total is below read |
BadTtl |
blocking.ttl, cache.negative_ttl_max, a record ttl, web.session_ttl_hours or blocklist_update.interval_hours outside its range |
BadRetention |
logging.retention_days or logging.query_log_buffer_max below 1 |
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 |
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 |
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 at import and never stored;
// 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 "*".
.rules = .{
.{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
.{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .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" },
},
}