Andrew Mercer
on this page

LAN-facing recursive resolver that forwards upstream over TLS, plus local zone overrides.

References used while building this

  • https://quad9.net
  • https://ev1z.be/2020/12/04/unbound-dns-over-tls-forwarding-server-on-debian-buster
  • https://www.ctrl.blog/entry/unbound-tls-forwarding.html
  • https://wiki.archlinux.org/title/unbound
  • https://calomel.org/unbound_dns.html
  • https://utcc.utoronto.ca/~cks/space/blog/sysadmin/UnboundLocalDNSOverride

Install (Debian)

sudo apt-get -y install unbound

Configure Unbound

/etc/unbound/unbound.conf.d/main.conf

server:
    # print statistics to the log (for every thread) every N seconds.
    # Set to "" or 0 to disable. Default is disabled.
    statistics-interval: 300

    # enable cumulative statistics, without clearing them after printing.
    statistics-cumulative: yes
    # enable extended statistics (query types, answer codes, status)
    # printed from unbound-control. default off, because of speed.
    extended-statistics: yes

    # specify the interfaces to answer queries from by ip-address.
    interface: 192.168.0.52
    #interface: fd00:470:780b:120::102

    # specify the interfaces to send outgoing queries to authoritative
    # server from by ip-address. If none, the default (all) interface
    # is used. Specify every interface on a 'outgoing-interface:' line.
    outgoing-interface: 192.168.0.52

    # Set this to yes to prefer ipv6 upstream servers over ipv4.
    prefer-ip6: no

    # Detach from the terminal, run in background, "yes" or "no".
    # Set the value to "no" when unbound runs as systemd service.
    do-daemonize: no

    # control which clients are allowed to make (recursive) queries
    # to this server. Specify classless netblocks with /size and action.
    access-control: 192.168.0.0/24 allow
    access-control: 0.0.0.0/0 refuse

    # if given, a chroot(2) is done to the given directory.
    chroot: "/var/lib/unbound"

    # if given, user privileges are dropped (after binding port),
    # and the given username is assumed. Default is user "unbound".
    username: "unbound"

    # the working directory. The relative files in this config are
    # relative to this directory.
    directory: "/etc/unbound"

    # Log to syslog(3) if yes.
    use-syslog: yes

    # DNSSEC trust anchor, kept current by unbound-anchor
    auto-trust-anchor-file: "/var/lib/unbound/root.key"

## Forward zones
forward-zone:
    name: "."
    forward-addr: 208.67.222.222
    forward-addr: 208.67.220.220
    forward-addr: 8.8.8.8
    forward-addr: 8.8.4.4

Changes from the original draft, and why:

  • Removed the bare outgoing-interface: "192.168.0.52" quoting inconsistency — the interface value doesn't need (and shouldn't have) surrounding quotes; kept consistent with interface: above it.
  • Added an explicit access-control: 0.0.0.0/0 refuse. The original config relied on access-control: 192.168.0.0/24 allow alone with nothing else specified — Unbound does not implicitly deny everything else just because one allow rule exists; being explicit about the deny-everything-else case is what actually prevents this from becoming an open resolver if it's ever reachable from outside the LAN (e.g., a future firewall or routing change).
  • Added auto-trust-anchor-file. The original had dnssec-enable-style validation implied but no trust anchor configured — without this, there's nothing for Unbound to validate signed answers against. On Debian this is normally pulled in automatically by a separate packaged include (root-auto-trust-anchor-file.conf); it's called out explicitly here since this config was being hand-assembled rather than left at package defaults, and because the module-configuration "todo" in the original notes was really pointing at exactly this gap along with forwarding.
  • Filled in the module-configuration "todo". The forwarders were already present in the original file, so the only missing piece for "module configuration i.e. forwards in forwarder.conf" was the trust-anchor line above and, if you want forwarding split into its own file rather than living in main.conf, moving the forward-zone block into a separate /etc/unbound/unbound.conf.d/forwarder.conf — functionally identical, just organized the way the todo note implied it should be:

/etc/unbound/unbound.conf.d/forwarder.conf (optional split, if you want forwarding config separated from main.conf)

forward-zone:
    name: "."
    forward-addr: 208.67.222.222
    forward-addr: 208.67.220.220
    forward-addr: 8.8.8.8
    forward-addr: 8.8.4.4

(remove the forward-zone block from main.conf if you split it out this way, to avoid two conflicting definitions loading at once.)

Configure Firewall for Unbound

UFW

sudo ufw allow dns

This opens 53/udp and 53/tcp. If DoT forwarding is enabled per below, also allow the outbound TLS port to your chosen upstream:

sudo ufw allow out 853/tcp

Configure Unbound for TLS

The original notes installed ca-certificates and left tls.conf empty — this is where the actual DoT forwarding gets turned on, replacing the plaintext forwarders above with TLS-authenticated ones:

sudo apt-get -y install ca-certificates

/etc/unbound/unbound.conf.d/tls.conf

server:
    tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt"

forward-zone:
    name: "."
    forward-tls-upstream: yes
    forward-addr: 9.9.9.9@853#dns.quad9.net
    forward-addr: 149.112.112.112@853#dns.quad9.net

Given the Quad9 link in your reference list, Quad9 is used here as the upstream — swap in Cloudflare (1.1.1.1@853#cloudflare-dns.com) or Google (8.8.8.8@853#dns.google) if preferred. A few things worth being deliberate about:

  • This forward-zone block replaces the plaintext one in main.conf/forwarder.conf, rather than adding to it — Unbound doesn't merge two forward-zone name: "." blocks meaningfully; whichever loads last (or the resulting duplicate) is the point of confusion to avoid. Comment out or delete the plaintext forwarders once TLS forwarding is confirmed working.
  • The #dns.quad9.net suffix matters. Without it, Unbound still encrypts the connection but doesn't verify the certificate is actually Quad9's — encrypted-but-unauthenticated is a materially weaker guarantee than what DoT is meant to provide, so don't drop that suffix even though the config still "works" without it.
  • tls-cert-bundle must point at a real, present CA bundle — /etc/ssl/certs/ca-certificates.crt is correct for Debian/Ubuntu once ca-certificates is installed (as above); remember this path is interpreted inside the chroot once chroot: "/var/lib/unbound" is active (see next section), so confirm the bundle is actually reachable at /var/lib/unbound/etc/ssl/certs/ca-certificates.crt on disk, or Unbound will fail to validate the upstream's certificate.

Verify DoT is actually in use rather than silently falling back to plaintext:

sudo unbound-control stats | grep -i tls

Add a Local Zone

This was the other "todo" in the original notes. A minimal example for overriding a couple of names on the LAN without standing up a full authoritative zone:

/etc/unbound/unbound.conf.d/local-zone.conf

server:
    local-zone: "home.arpa." static
    local-data: "nas.home.arpa. IN A 192.168.0.10"
    local-data: "printer.home.arpa. IN A 192.168.0.20"
    local-data-ptr: "192.168.0.10 nas.home.arpa."
    local-data-ptr: "192.168.0.20 printer.home.arpa."

home.arpa. is the IANA-reserved name specifically intended for this kind of private/local use (RFC 8375), which is preferable to inventing an arbitrary internal TLD. static means anything in this zone not explicitly listed above returns NXDOMAIN rather than falling through to the real internet — use transparent instead of static if you want unlisted names under the same zone to resolve normally rather than fail.

Apply and check:

sudo unbound-checkconf
sudo systemctl restart unbound
dig @192.168.0.52 nas.home.arpa
dig @192.168.0.52 example.com   # confirm normal + DoT-forwarded resolution still works

Troubleshooting Notes

  • Config loads but DNSSEC validation fails everything (SERVFAIL across the board): check the trust anchor file actually exists at the chrooted path (/var/lib/unbound/root.key) and that unbound-anchor has run at least once; also check system clock — signature validity windows are time-based.
  • DoT forwarding silently not encrypting: confirm the tls-cert-bundle path resolves correctly under the chroot (see above) — a missing bundle is a common silent-fallback cause depending on build/version.
  • Server unreachable from LAN clients despite access-control allowing it: check the firewall rule from the UFW section actually applied, and that interface: is bound to the address clients are pointed at, not just 127.0.0.1.