# 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](../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/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](files-and-directories.md). |
| `--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. |
| `--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.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](#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](../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`; 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–1000000 | 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](files-and-directories.md#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`:

```zig
.{ .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`).

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

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.password` is operator input only. 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 = null`.
- `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.

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](../how-to/set-up-admin-authentication.md).

## 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 |
| `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 `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–1000000 |
| `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.

```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 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 "*".
    .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" },
    },
}
```
