Unbound, Local DNS Resolver

Posted on 3 2026

The network section introduced Unbound as the local DNS resolver for the desktop. This page covers the full configuration in detail: what Unbound is doing, how it handles DNSSEC validation, how it forwards internal domain queries to your own DNS server, and how it integrates with NetworkManager so the right resolvers are used on every network you connect to.

The source material this series draws on uses dnssec-trigger alongside Unbound to test upstream resolvers and reconfigure Unbound dynamically. That approach is not recommended here. dnssec-trigger has a known crashing bug, is effectively unmaintained, and adds significant complexity for limited benefit. The configuration below uses Unbound directly, with a NetworkManager dispatcher script handling the split DNS behaviour that dnssec-trigger was responsible for.

What Unbound does

Unbound is a validating, recursive, caching DNS resolver. It is designed to be fast and lean and incorporates modern features based on open standards.

In practice, for a desktop, it does three important things. First, it validates DNSSEC signatures on DNS responses, so a spoofed or tampered answer is rejected rather than used. Second, it caches responses locally, so repeat queries for the same domain are answered in microseconds rather than making a network round-trip. Third, it forwards queries for specific domains, your internal network domains, to your own authoritative DNS server rather than the public internet.

Prerequisites

Ensure systemd-resolved is not occupying port 53, which would conflict with Unbound:

sudo ss -tulpn | grep :53

If systemd-resolved is listening on port 53 (not just the stub listener at 127.0.0.53), disable its stub listener:

sudo mkdir -p /etc/systemd/resolved.conf.d
sudo tee /etc/systemd/resolved.conf.d/no-stub.conf << 'EOF'
[Resolve]
DNSStubListener=no
EOF
sudo systemctl restart systemd-resolved

Installation

sudo apt install unbound dns-root-data

The dns-root-data package provides the root hints file (/usr/share/dns/root.hints) and the root trust anchor file, both of which Unbound needs for recursive resolution and DNSSEC validation.

Configuration

Unbound’s configuration lives in /etc/unbound/unbound.conf and the drop-in directory /etc/unbound/unbound.conf.d/. Put custom configuration in the drop-in directory to keep it clean and separate from the package defaults.

Remote control

Unbound’s remote control interface allows unbound-control to manage the running daemon without restarting it. This is required for the NetworkManager dispatcher script to reload Unbound’s forward zones when connections change.

Create /etc/unbound/unbound.conf.d/remote-control.conf:

remote-control:
    # Enable the remote control interface
    control-enable: yes

    # Listen on localhost only
    control-interface: 127.0.0.1
    control-interface: ::1

    # Port for remote control
    control-port: 8953

    # Certificate and key files
    # Generated by unbound-control-setup
    server-key-file: /etc/unbound/unbound_server.key
    server-cert-file: /etc/unbound/unbound_server.pem
    control-key-file: /etc/unbound/unbound_control.key
    control-cert-file: /etc/unbound/unbound_control.pem

Generate the certificate and key files:

sudo unbound-control-setup

Main server configuration

Create /etc/unbound/unbound.conf.d/local.conf:

server:
    #--------------------------------------
    # Interface and access control
    #--------------------------------------

    # Listen on localhost only
    interface: 127.0.0.1
    interface: ::1
    port: 53

    # Only allow queries from localhost
    access-control: 127.0.0.0/8 allow
    access-control: ::1 allow

    #--------------------------------------
    # DNSSEC
    #--------------------------------------

    # Root trust anchor for DNSSEC validation
    auto-trust-anchor-file: "/var/lib/unbound/root.key"

    # Root hints file
    root-hints: "/usr/share/dns/root.hints"

    #--------------------------------------
    # Privacy and security
    #--------------------------------------

    # Send minimal query names to authoritative servers
    qname-minimisation: yes

    # Aggressive NSEC reduces queries for non-existent names
    aggressive-nsec: yes

    # Use 0x20 randomisation to harden against spoofing
    use-caps-for-id: yes

    # Harden against various attacks
    harden-glue: yes
    harden-dnssec-stripped: yes
    harden-algo-downgrade: yes
    harden-referral-path: yes
    harden-short-bufsize: yes
    harden-large-queries: yes

    # Hide resolver version and identity
    hide-version: yes
    hide-identity: yes

    # Refuse id.server and hostname.bind queries
    identity: ""
    version: ""

    #--------------------------------------
    # Performance and caching
    #--------------------------------------

    # Cache size
    msg-cache-size: 50m
    rrset-cache-size: 100m

    # Prefetch popular entries before they expire
    prefetch: yes
    prefetch-key: yes

    # Cache minimum TTL
    cache-min-ttl: 300

    # Number of threads (match to CPU cores, 1 is fine for a desktop)
    num-threads: 1

    # EDNS buffer size (recommended for modern networks)
    edns-buffer-size: 1232

    #--------------------------------------
    # Logging
    #--------------------------------------

    # Log to syslog
    use-syslog: yes

    # Verbosity: 0 = errors only, 1 = operational info, 2 = detailed
    verbosity: 0

    # Log queries (disable in production, useful for debugging)
    # log-queries: yes

    #--------------------------------------
    # Local zone for RFC 1918 addresses
    #--------------------------------------

    # Do not forward RFC 1918 reverse lookups to the internet
    local-zone: "10.in-addr.arpa." nodefault
    local-zone: "168.192.in-addr.arpa." nodefault
    local-zone: "16.172.in-addr.arpa." nodefault
    local-zone: "17.172.in-addr.arpa." nodefault
    local-zone: "18.172.in-addr.arpa." nodefault
    local-zone: "19.172.in-addr.arpa." nodefault
    local-zone: "20.172.in-addr.arpa." nodefault
    local-zone: "21.172.in-addr.arpa." nodefault
    local-zone: "22.172.in-addr.arpa." nodefault
    local-zone: "23.172.in-addr.arpa." nodefault
    local-zone: "24.172.in-addr.arpa." nodefault
    local-zone: "25.172.in-addr.arpa." nodefault
    local-zone: "26.172.in-addr.arpa." nodefault
    local-zone: "27.172.in-addr.arpa." nodefault
    local-zone: "28.172.in-addr.arpa." nodefault
    local-zone: "29.172.in-addr.arpa." nodefault
    local-zone: "30.172.in-addr.arpa." nodefault
    local-zone: "31.172.in-addr.arpa." nodefault

Forward zones for internal domains

Create /etc/unbound/unbound.conf.d/forward-zones.conf to forward internal queries to your own DNS server. This file is managed by the NetworkManager dispatcher script when connections change, but a sensible default is worth having in place:

# Forward internal domains to the home network DNS server
# These zones are updated dynamically by the NetworkManager dispatcher
# when the connection changes. The addresses here are the defaults
# for Burnage Mad House (Core VLAN, 10.1.0.0/24).

forward-zone:
    name: "yourdomain.net"
    forward-addr: 10.1.0.1
    forward-first: no

forward-zone:
    name: "lan"
    forward-addr: 10.1.0.1
    forward-first: no

# Reverse zones for all three sites
forward-zone:
    name: "1.10.in-addr.arpa."
    forward-addr: 10.1.0.1
    forward-first: no

forward-zone:
    name: "2.10.in-addr.arpa."
    forward-addr: 10.1.0.1
    forward-first: no

forward-zone:
    name: "3.10.in-addr.arpa."
    forward-addr: 10.1.0.1
    forward-first: no

Replace 10.1.0.1 with your actual DNS server address, and yourdomain.net with your actual domain.

Integrating with NetworkManager

The dispatcher script updates Unbound’s forward zones when the network connection changes, so the right DNS server is used for internal domains regardless of which site you are at.

Create /etc/NetworkManager/dispatcher.d/20-update-unbound:

#!/usr/bin/env bash
#
# Update Unbound forward zones based on the current network connection.
# Runs when a NetworkManager connection comes up or goes down.
#

INTERFACE=$1
ACTION=$2
CONF_FILE=/etc/unbound/unbound.conf.d/forward-zones.conf

case "$ACTION" in
    up|vpn-up)
        # Get DNS servers from the current connection
        DNS_SERVERS=$(nmcli -g IP4.DNS device show "$INTERFACE" 2>/dev/null | \
            tr '|' '\n' | grep -v '^$')

        if [ -z "$DNS_SERVERS" ]; then
            exit 0
        fi

        # Get search domains from the current connection
        SEARCH_DOMAINS=$(nmcli -g IP4.DOMAIN device show "$INTERFACE" 2>/dev/null | \
            tr '|' '\n' | grep -v '^$')

        # Write new forward zone configuration
        {
            echo "# Generated by $(basename "$0") for $INTERFACE"
            echo "# Updated: $(date)"

            for DOMAIN in $SEARCH_DOMAINS; do
                echo ""
                echo "forward-zone:"
                echo "    name: \"${DOMAIN}\""
                for SERVER in $DNS_SERVERS; do
                    echo "    forward-addr: ${SERVER}"
                done
                echo "    forward-first: no"
            done

            # Always forward RFC 1918 reverse zones to internal DNS
            for SERVER in $DNS_SERVERS; do
                for ZONE in "1.10.in-addr.arpa." "2.10.in-addr.arpa." "3.10.in-addr.arpa."; do
                    echo ""
                    echo "forward-zone:"
                    echo "    name: \"${ZONE}\""
                    echo "    forward-addr: ${SERVER}"
                    echo "    forward-first: no"
                done
            done
        } > "$CONF_FILE"

        # Reload Unbound
        unbound-control reload 2>/dev/null || systemctl restart unbound
        ;;

    down|vpn-down)
        # Clear forward zones when connection drops
        echo "# Cleared by $(basename "$0") - connection down" > "$CONF_FILE"
        unbound-control reload 2>/dev/null || systemctl restart unbound
        ;;
esac

Make it executable:

sudo chmod 0744 /etc/NetworkManager/dispatcher.d/20-update-unbound
sudo chown root:root /etc/NetworkManager/dispatcher.d/20-update-unbound

Tell NetworkManager to use Unbound

Create /etc/NetworkManager/conf.d/unbound-dns.conf:

[main]
# Use Unbound for DNS resolution
dns=unbound

# Do not touch /etc/resolv.conf
rc-manager=unmanaged

Update /etc/resolv.conf to point at localhost:

sudo tee /etc/resolv.conf << 'EOF'
# Managed manually - DNS handled by local Unbound instance
nameserver 127.0.0.1
nameserver ::1
EOF

Make it immutable to prevent other tools from overwriting it:

sudo chattr +i /etc/resolv.conf

If you ever need to edit it, remove the immutable flag first with sudo chattr -i /etc/resolv.conf.

Validate the configuration

Check the configuration file for errors before starting:

sudo unbound-checkconf

Start and enable Unbound:

sudo systemctl enable --now unbound

Restart NetworkManager to apply the DNS configuration change:

sudo systemctl restart NetworkManager

Testing

Test basic resolution:

dig google.com @127.0.0.1

Test DNSSEC validation. A correctly signed domain should return with the ad flag:

dig +dnssec sigok.verteiltesysteme.net @127.0.0.1

Check the flags line in the output for ad (authentic data). Then test a known DNSSEC-failing domain, which should return SERVFAIL:

dig sigfail.verteiltesysteme.net @127.0.0.1

Test internal domain resolution (once your DNS server is running):

dig server.yourdomain.net @127.0.0.1

Test reverse resolution:

dig -x 10.1.0.1 @127.0.0.1

Check Unbound’s statistics:

sudo unbound-control stats_noreset | grep -E "total\.|cache\."

Troubleshooting

Port conflict with systemd-resolved:

sudo ss -tulpn | grep :53
sudo systemctl stop systemd-resolved

DNSSEC trust anchor fails to load:

The trust anchor is auto-updated by Unbound. If it fails on first start, generate it manually:

sudo unbound-anchor -a /var/lib/unbound/root.key
sudo chown unbound:unbound /var/lib/unbound/root.key

Forward zone not updating on connection change:

Check the dispatcher script is running:

sudo journalctl -u NetworkManager | grep dispatcher

Test the script manually:

sudo INTERFACE=enp3s0 ACTION=up CONNECTION_UUID=test \
    /etc/NetworkManager/dispatcher.d/20-update-unbound enp3s0 up

Queries timing out:

Verify Unbound is listening:

sudo ss -tulpn | grep unbound

Check the Unbound log:

sudo journalctl -u unbound -f

ISPs increasingly intercept DNS queries on port 53 and redirect them to their own resolvers. If DNSSEC validation tests unexpectedly fail and you are on a network you do not control, DNS hijacking by the upstream provider is worth investigating before concluding the configuration is wrong.