Files
nxdns/docs/reference/configuration.md
T

24 KiB
Raw Blame History

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 100120000 read deadline on conditional-forward-zone exchanges (src/local/forward_client.zig)
upstream.total_timeout_ms u32 5000 ms 100120000, 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 165535 (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 13600 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 165535 (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 165535 (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 165535 (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; 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
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 1604800 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.

Validation errors

nxdns check, nxdns import and the nxdns run that seeds a database from 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.

One credential this cannot remove: a NextDNS DoT upstream is tls://abcd12.dns.nextdns.io, which carries the same identifier in the hostname. Stripping it would leave no host at all and no line worth reading. So if you use NextDNS over DoT, your profile id appears in the log.

The host is kept 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 cost every operator a diagnostic to cover one vendor's choice. If that trade is wrong for you, it is yours to make rather than ours: NextDNS also publishes a DoH endpoint, https://dns.nextdns.io/abcd12, whose identifier sits in the path and is redacted in full. Configure that form instead and nothing identifying reaches the log.

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 100120000 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

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 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" },
    },
}