The node reads a YAML file, by default /etc/opdns/node.yaml (optional
unless you pass --config or set OPDNS_CONFIG), then applies
environment variables. From the source:
Package config is the self-hosted node’s configuration: a YAML file
(default /etc/opdns/node.yaml) with OPDNS_* environment overrides.
Every key can be overridden by the environment variable OPDNS_ followed by
its upper-cased path joined with underscores: unbound.spawn is
OPDNS_UNBOUND_SPAWN, data_dir is OPDNS_DATA_DIR, listen.udp is
OPDNS_LISTEN_UDP (lists are comma separated; an empty value clears one).
Precedence: flag > environment > file > default. Unknown file keys are
errors.
The file carries a version (1 today; a file without one is version 1).
An older file is migrated when it is loaded; a file from a newer release
is refused, so a downgrade cannot misread it.
opdns-node config check validates a file without starting anything
(unknown keys are errors) and prints the effective configuration, with
secrets redacted and the source of every value as a comment:
# default, # file, # env OPDNS_… or # flag --…. It
exits with status 2 on an invalid file. opdns-node run --print-config
prints the same for what run would use, and
opdns-node config reference prints the tables below from the binary
itself.
Terminal window
opdns-nodeconfigcheck/etc/opdns/node.yaml
opdns-noderun--print-config
Command-line flags that override the file: --config FILE,
--data-dir DIR, --log-level LEVEL, and for run only
--profile-file FILE, --lists-url URL and --enrol-token CODE.
SIGHUP (systemctl reload opdns-node, or
docker kill -s HUP opdns-node) reads the configuration again and
applies these keys at once, without dropping queries:
dns.allow_from, web.allow_from, web.hosts,
web.password_hash, store.retention_days, local.hosts and
unbound.ipv6 (the host’s IPv6 route is checked again too; a changed
result restarts Unbound). Any other changed key
keeps its running value and is logged as needing a restart. An invalid
file changes nothing: the node logs the error and keeps its configuration.
The same signal also pulls the profile and lists again and reloads the TLS
certificate. A profile_file needs no signal: the node reloads it when
it changes (Standalone mode).
With mode empty (the default) the node is enrolled when its data
directory holds a node token and standalone when the configuration has
a profile. With neither, and no enrol.token, run prints how to
enrol and exits with status 2; it never serves unfiltered DNS for lack of
setup. An invalid configuration also exits with status 2.
The node installs a list artifact only if it verifies with a trusted
Ed25519 key: one of lists.public_keys, or, while
lists.trust_cloud_key is on (the default), the key the list source
publishes. An enrolled node needs neither setting.
Serves /, /api/status, /healthz and, with metrics, /metrics (empty disables the page).
page.metrics
OPDNS_PAGE_METRICS
bool
false
Serves Prometheus metrics at /metrics on the page address: the edge metric set plus the node’s (link state, log backlog, SQLite size, writer queue depth, dropped records, shed queries). Off by default.
CIDRs (or addresses) allowed to open the page and the API besides the defaults: RFC 1918, CGNAT 100.64/10, ULA, link-local and loopback. Every other source gets 403.
web.hosts
OPDNS_WEB_HOSTS
list of strings
Names accepted in the Host header besides IP literals, localhost and the node’s name (with and without .local). Any other Host gets 421, which defeats DNS rebinding.
web.api_token
OPDNS_WEB_API_TOKEN
string
The bearer token of the standalone local REST API. When empty, one is generated on first run, kept in <data_dir>/secrets/api_token and shown once on the local page.
web.password_hash
OPDNS_WEB_PASSWORD_HASH
string
PasswordHash, when set, protects the page, /api/status and /metrics with a password (HTTP Basic, any user name): an argon2id hash from opdns-node hash-password. /healthz and the local REST API (bearer token) are not affected.
Optionally pins the TLS keys of the cloud API and link hosts: base64 SHA-256 hashes of a SubjectPublicKeyInfo (“sha256/<base64>” or bare base64), matched against every certificate of the verified chain. Off by default, since a pin breaks behind TLS-inspecting proxies and on CA changes; list integrity never depends on it (artifacts are signed).
cloud.profile_public_keys
OPDNS_CLOUD_PROFILE_PUBLIC_KEYS
list of strings
Pins the hex Ed25519 keys the cloud signs profile responses with (threat model G-1). Enrolled nodes also trust the keys the cloud announces at enrolment and in verified responses (kept in profile_keys.json); with any key trusted, an unsigned or badly signed profile is refused and the last good one kept. Empty: learnt keys only.
How many relayed dashboard queries (tail, search and the analytics shapes) run against the log store at once. A query that finds every slot busy waits briefly, then is shed: it is answered with no rows, partial and partial_reason “overload”, so the dashboard shows a partial result instead of an error. Lower it on small hardware; default 4 (the cloud’s own per-node limit).
link.token_rotation
OPDNS_LINK_TOKEN_ROTATION
duration (e.g. 30s, 15m, 6h)
2160h
How often the node rotates its node token over the link: the cloud issues a new token, the node stores it in node.json before switching to it, and the previous token keeps working for 10 minutes. 0 turns automatic rotation off (opdns-node rotate-token still works). Default 90 days.
Hex Ed25519 keys; an artifact must verify with one.
lists.trust_cloud_key
OPDNS_LISTS_TRUST_CLOUD_KEY
bool
true
Also trusts the key the list source publishes: the cloud’s lists_pubkey on every profile pull (enrolled), or the pubkey of the latest.json next to (or at) lists.url (standalone). Learnt keys are kept in the data dir for offline boots. Turn it off to pin lists.public_keys only.
lists.url
OPDNS_LISTS_URL
string
The initial (enrolled) or only (standalone) artifact URL. In standalone mode it may also name a latest.json pointer, which is followed to the newest artifact on every poll.
Off disables validation entirely (offline dev only).
unbound.ipv6
OPDNS_UNBOUND_IPV6
string
"auto"
How the spawned Unbound uses IPv6 for its upstream queries: “auto” probes the host at start and on SIGHUP (no IPv6 route: do-ip6 no; a route but no public IPv6 source address: prefer-ip4; else both families), “on” always uses both, “prefer-ip4” prefers IPv4, “off” never uses IPv6. Without IPv6 egress a cold lookup otherwise waits on IPv6 timeouts first and fails more often.
unbound.stubs
OPDNS_UNBOUND_STUBS
list of strings
Zone=addr[@port][;addr] stub zones (offline dev).
unbound.upstream
OPDNS_UNBOUND_UPSTREAM
string
Used instead of a spawned Unbound when Spawn is false.
The low-disk watermark: below this much free space on the data directory’s file system, query logging pauses (DNS keeps answering, the local page says so) until space is back. 0 disables. Reloaded on SIGHUP.
Forward zones to fixed servers (file only; the unbound.stubs string form also accepts a trailing “!” for insecure).
dns.allow_from
OPDNS_DNS_ALLOW_FROM
list of strings
CIDRs (or addresses) allowed to query the node besides the defaults (RFC 1918, CGNAT 100.64/10, ULA, link-local, loopback): the open-resolver guard. Other sources get REFUSED on Do53 (UDP and TCP); their DoT, DoH and DoQ connections are closed after the handshake. 0.0.0.0/0 or ::/0 turns the guard off, which the local page warns about.
dns.public_transports
OPDNS_DNS_PUBLIC_TRANSPORTS
bool
false
Lets any source use the encrypted listeners (DoT, DoH, DoQ, DoH3); Do53 stays guarded.
dns.root_zone
OPDNS_DNS_ROOT_ZONE
bool
true
Keeps a local copy of the root zone in the spawned Unbound (RFC 8806): its iterator answers root referrals from the copy instead of asking a root server, so a cold lookup starts at the TLD servers. The copy is ZONEMD-verified (and DNSSEC-validated when validation is on), lives in <data_dir>/unbound/root.zone and is refreshed from dns.root_zone_sources. When it is missing, invalid or expired Unbound queries the root servers as usual, so turning it off or losing it never stops resolution; the local page warns once the copy is two days old. Ignored with unbound.spawn false.
Where the root zone copy comes from: IP addresses (optionally addr@port) that serve it by AXFR, and http(s) URLs of the zone file, which Unbound prefers for downloads (probing the addresses’ SOA for changes). The default is RFC 8806’s list (b, c, d, f, g and k root, xfr.lax and xfr.cjr.dns.icann.org, IPv4 and IPv6) plus ICANN’s HTTPS copy; a node that cannot reach them (a firewall allowing only DNS to its upstream) can name a local server that serves the zone by AXFR.
The local domains the node answers itself, never asking the internet: hosts below them from local.hosts and the lease files, NXDOMAIN for the others. The first names PTR answers. Default home.arpa (RFC 8375); a name outside home.arpa, .lan, .home, .internal and the other local suffixes may be caught by rebinding protection.
local.hosts
OPDNS_LOCAL_HOSTS
list of strings
Static names, “name=address[,address]”: a label (answered under every local domain) or a name under one of them. They win over lease host names. Reloaded on SIGHUP.
local.lease_files
OPDNS_LOCAL_LEASE_FILES
list of strings
DHCP lease files (dnsmasq, ISC dhcpd, Kea CSV; the format is detected) whose host names are answered under the local domains, with PTR records, and name the clients in the query logs. Polled every lease_poll; the list is reloaded on SIGHUP.
local.lease_poll
OPDNS_LOCAL_LEASE_POLL
duration (e.g. 30s, 15m, 6h)
30s
How often the lease files are checked for changes.
local.forward
OPDNS_LOCAL_FORWARD
string
The router (address or address:port) asked for local names and private addresses the node does not know (conditional forwarding); empty answers them NXDOMAIN. Private reverse lookups never leave the LAN either way.
local.device_names
OPDNS_LOCAL_DEVICE_NAMES
bool
true
Names clients without a device id (plain DNS) in the node’s query logs after their lease or static host name.