Skip to content

Self-hosted node

The self-hosted node, opdns-node, is the same resolver as the cloud: opdns-edge’s listeners and policy engine in one process, supervising its own Unbound, with a SQLite file for logs and a small status page. Put it on a Raspberry Pi, a home server or a NAS and point your network at it.

Why run one:

  • It keeps working when the internet or opdns is down. Filtering uses the last profile and lists it received.
  • Logs stay at home. Queries your devices send to the node are logged in its SQLite file and nowhere else.
  • Lower latency for everything on your network.
Enrolled Standalone
Account required none
Profile from the dashboard, pushed to the node over its link in the node’s config file
Lists the signed list artifact opdns publishes the same public artifact
Logs the node’s SQLite; plus the cloud’s logs for the profile if you choose the node’s SQLite
Manage it from the dashboard (logs and analytics are read through the link); a local status page the config file; a local status page
When opdns is unreachable keeps resolving with the last profile and lists unaffected

A node that is neither enrolled nor given a standalone profile does not serve DNS at all: it says how to enrol and exits. The beta ships enrolled mode. Standalone mode works, with a small local API but no editing page yet; see Standalone mode.

Either way, the node answers only devices on your own network unless you allow more; see Network access.

A node serves one profile in the beta. Per-client profiles (different profiles for different devices on your LAN) come with Phase 4’s local network features; local names from DHCP leases and private PTR records are already answered (Local names).

The node installs a list artifact only if it verifies with a trusted Ed25519 key and matches the announced hash. What the cloud announces is what an enrolled node runs: a lists changed notice over the link, or the lists of a profile pull, installs the named version even when it is older than the one in use. That is how an operator’s rollback of a bad list build reaches your node. Enrolled nodes follow only fleet-wide versions, never a build still in its canary.

The last announced version is kept in lists_announced.json in the data directory. A notice for an older version that arrives within 30 seconds of a newer announcement is treated as a replay of an old message and ignored, not as a rollback. A standalone node never goes back silently: if polling finds an older latest.json than the artifact it has, it refuses it, keeps its current lists and shows the error in opdns-node status.

An enrolled node checks that the profile it receives comes from opdns: every profile the cloud sends is signed with Ed25519 (profile_signature over the profile’s id, version and content). The node trusts:

  • the keys the cloud announces: at enrolment, then in every profile response that verifies, so a key rotation needs nothing from you. A node enrolled before the cloud signed profiles learns the key on its first pull that announces one. Learnt keys are kept in <data_dir>/profile_keys.json, backed up with the node and forgotten on unenrol;
  • the keys you pin with cloud.profile_public_keys (OPDNS_CLOUD_PROFILE_PUBLIC_KEYS, hex), if you want trust not to depend on what the node learnt at enrolment.

Once any key is trusted, an unsigned or badly signed profile is refused: the node keeps resolving with its last good profile, opdns-node status and the local page show the error, and opdns_node_profile_signature_failures_total counts it. A key the cloud stops announcing stays trusted until the next pull confirms the new set.

The first key is trusted on first use: a node that is enrolled through a compromised path would learn the wrong key, which is what pinning prevents (an open decision, like the rest of the trust model). If the cloud’s key is ever lost and replaced, stop the node, delete profile_keys.json, and start it again so it learns the new key.

The node answers your network’s own names itself, in both modes, without asking the internet:

  • Local domains, local.domains (default home.arpa, RFC 8375): hosts from local.hosts (name=address[,address], a bare label is answered under every local domain) and host names from DHCP lease files in local.lease_files (dnsmasq, ISC dhcpd or Kea CSV, detected; polled every local.lease_poll, 30 seconds). Static hosts win over leases. local.hosts is reloaded on SIGHUP.
  • Reverse lookups of private addresses (RFC 1918, 169.254/16, fc00::/7, fe80::/10): PTR answers from the same table.

A name or private address the node does not know is answered NXDOMAIN locally, or asked of your router when local.forward names it (conditional forwarding). Either way, private reverse lookups never leave your network. Local names are answered after the profile’s policy, so a rewrite of the same name still wins over them. The local page shows how many names are answered and when the leases were last read.

The default is on with nothing configured: home.arpa names you have not declared get NXDOMAIN, and reverse lookups of private addresses are kept local. Answering the local names by default is an open decision. A local domain outside home.arpa, .lan, .home, .internal and the other local suffixes may be caught by rebinding protection. With local.device_names on (the default), queries from clients that send no device name are logged under their lease or host name (Logs on your node).

Unbound can ask authoritative servers over IPv4 and IPv6. On a host without working IPv6, trying IPv6 first makes cold lookups slow and failures more frequent, so the node checks the host at start and on every reload (SIGHUP): it asks the kernel for a route to a public IPv6 address, without sending a packet.

The host has Unbound is told
no IPv6 route IPv4 only (do-ip6: no)
a route, but only a private or unique-local IPv6 source address prefer IPv4 (prefer-ip4: yes), as the PoPs without an IPv6 source
a public IPv6 source address both families

unbound.ipv6 overrides the check: auto (default), on, prefer-ip4 or off. A change of the result restarts Unbound. The local page and /api/status (unbound.ipv6) show the route, the source address and what Unbound uses. This concerns only the node’s own queries to the internet (and only a node that runs its own Unbound, not one with unbound.spawn: false); your devices can still query the node over IPv6.

Like every opdns PoP, the node’s Unbound keeps its own copy of the DNS root zone (RFC 8806), on by default. Root referrals come from the copy, so a lookup that is not cached starts at the top-level domain’s servers instead of asking a root server: slightly faster, and while the copy is valid the root servers do not see which top-level domains your network looks up.

  • The copy is <data_dir>/unbound/root.zone (about 2 MB). Unbound refreshes it on the zone’s own timers, from the sources in dns.root_zone_sources: by default the root servers that allow transfers (b, c, d, f, g and k, plus ICANN’s xfr.lax and xfr.cjr, over IPv4 and IPv6) and ICANN’s HTTPS copy at www.internic.net.
  • Every copy is checked against its ZONEMD digest (and its DNSSEC signatures while validation is on) before it is used.
  • A missing, rejected or expired copy never stops resolution: Unbound simply asks the root servers, as any resolver does. /healthz does not depend on it.
  • The status page and opdns-node status show the copy’s serial and age (unbound.root_zone in /api/status) and warn once it is two days old; the root zone normally changes about twice a day. With page.metrics on, /metrics has opdns_edge_root_zone_loaded, opdns_edge_root_zone_serial and opdns_edge_root_zone_updated_timestamp_seconds, the same series as the PoPs.

Transfers need outbound TCP port 53 (to the addresses) and HTTPS (to the URL). On a network that only lets DNS out to one server, set dns.root_zone_sources to a local server that serves the root zone by zone transfer, or turn the copy off with dns.root_zone: false (the environment variables are OPDNS_DNS_ROOT_ZONE and OPDNS_DNS_ROOT_ZONE_SOURCES). Both keys take effect on restart; see the configuration reference. A node with unbound.spawn: false ignores them.

  • Linux on amd64 or arm64 with Docker, or the static binary for linux/amd64, linux/arm64 or darwin/arm64. On Linux the binary runs as a hardened systemd service from the unit in the repository.
  • For the binary: Unbound installed on the host. The container includes it.
  • Nothing to configure for an enrolled node: the list signing key comes from the cloud.
  • Port 53 (UDP and TCP) free on the host, and 8053 for the status page.
  • Memory: the full list artifact is sized for under 1 GB. A smaller variant for 1 GB devices is planned (SH-055).

From an enrolled node, over its one outbound connection to link.opdns.net: the node’s id; its versions (node, Unbound, profile, lists); uptime and last sync time; SQLite file size, oldest record time and pending and dropped record counts; clock offset and whether Unbound is healthy; connection times and its public IP address. Never the queries it resolves or the logs it produces, except as answers to your own dashboard requests (relayed through opdns in transit, not stored). When such an answer lists destination addresses, the control plane adds their countries on the way, since the node has no GeoIP data (Analytics from your node).

From a standalone node: downloads of the public list artifact (IP address and time), kept 14 days in operational logs.

The details are in the privacy policy, section 7 and on Architecture.

The node is source-available under the Source First License 1.1: free for non-commercial use such as your home network. A business running it needs a commercial licence. See Licensing.

Next: install with Docker or the static binary.