# 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](../explanation/configuration-model.md). For the commands that read and write configuration, see [the CLI reference](cli.md). ## 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](files-and-directories.md). | | `--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](#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](../how-to/enable-doh-and-dot.md). | 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.password` is operator input only. At import time it is hashed with argon2id (OWASP parameters: t=2, m=19 MiB, p=1, PHC encoding) into `web.password_hash` and discarded. There is no `web.password` settings row, and `nxdns export` always writes `.password = ""`. - `web.password_hash` is 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](../how-to/set-up-admin-authentication.md). ## 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. ```zon .{ .groups = .{ .{ .name = "default" } }, .upstreams = .{ .{ .url = "https://dns.quad9.net/dns-query" } }, } ``` ## Fuller annotated example ```zon .{ // 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" }, }, } ```