| |

Setting Up Unbound Recursive DNS Resolver on Debian 13

A practical guide for system administrators running production servers. This guide focuses on the clean, reliable approach: running Unbound directly without systemd-resolved.

Why Run Your Own Recursive DNS Resolver?

When operating servers, especially mail servers, DNS reliability and performance are critical. Public DNS providers (Google, Cloudflare, Quad9) have a significant limitation: they intentionally rate-limit or block DNSBL (DNS-based Blacklist) queries used for spam detection. This causes:

  • Slow mail delivery
  • Failed spam checks
  • Incomplete DNSBL lookups
  • Delivery delays and timeouts

Running your own recursive DNS resolver with Unbound solves this completely:

  • No rate limiting – Direct queries to authoritative nameservers
  • DNS independence – No reliance on third-party services
  • Privacy – No external logging of your DNS queries
  • Performance – Excellent caching for repeated queries
  • Essential for mail servers – Unlimited DNSBL queries
  • DNSSEC validation – Built-in security and integrity checking

Prerequisites

  • Debian 12/13 (or similar systemd-based distribution)
  • Root or sudo access
  • Basic understanding of DNS
  • Server environment (physical, VM, or container)

Installation

sudo apt update
sudo apt install unbound unbound-anchor curl dnsutils -y

Packages installed:

  • unbound – The DNS resolver
  • unbound-anchor – DNSSEC trust anchor management
  • curl – For downloading root hints
  • dnsutils – Provides dig for testing

Configuration

Step 1: Download Root Hints

Root hints enable Unbound to perform recursive resolution starting from DNS root servers:

sudo curl -o /var/lib/unbound/root.hints https://www.internic.net/domain/named.root
sudo chown unbound:unbound /var/lib/unbound/root.hints
sudo chmod 644 /var/lib/unbound/root.hints

Step 2: Configure Unbound

Create the local configuration file:

sudo vi /etc/unbound/unbound.conf.d/local.conf

Add this configuration:

server:
    # Module configuration - disable subnetcache (not needed for local resolver)
    module-config: "validator iterator"
    
    # Network - listen on standard port
    interface: 127.0.0.1
    interface: ::1
    port: 53
    do-ip6: no
    
    # Access control
    access-control: 127.0.0.0/8 allow
    access-control: ::1 allow
    do-not-query-localhost: no
    
    # Recursive resolution
    root-hints: "/var/lib/unbound/root.hints"
    
    # Performance
    msg-cache-size: 50m
    rrset-cache-size: 100m
    cache-min-ttl: 300
    cache-max-ttl: 86400
    cache-max-negative-ttl: 3600
    prefetch: yes
    prefetch-key: yes
    
    # Privacy and security
    hide-identity: yes
    hide-version: yes
    qname-minimisation: yes
    harden-glue: yes
    harden-dnssec-stripped: yes
    use-caps-for-id: yes
    val-clean-additional: yes
    
    # DNSSEC
    auto-trust-anchor-file: "/var/lib/unbound/root.key"
    
    # Network optimization
    edns-buffer-size: 1232
    
    # Logging (set to 0 after testing)
    verbosity: 1
    log-queries: yes

Important configuration notes:

  • module-config: Specifies which Unbound modules to use. We’re using validator (for DNSSEC validation) and iterator (for recursive resolution). The default Debian configuration includes subnetcache which isn’t needed for a local resolver and causes a warning with prefetch enabled.
  • Port 53: Standard DNS port, since systemd-resolved won’t be running
  • do-ip6: no: IPv6 is disabled for server use. Spamhaus and some DNSBL providers falsely flag IPv6 queries as “open resolver” activity, potentially blocking your server from their services. Only enable IPv6 if you’ve verified all your DNSBL providers support it properly.

Remove duplicate configuration if it exists:

# Check for existing auto-trust-anchor-file configuration
ls /etc/unbound/unbound.conf.d/root-auto-trust-anchor-file.conf

# If it exists, remove it to avoid duplication
sudo rm /etc/unbound/unbound.conf.d/root-auto-trust-anchor-file.conf

Step 3: Disable systemd-resolved

systemd-resolved must be completely disabled. It creates DNS resolution files owned by systemd-resolve:systemd-resolve, which causes critical problems for chrooted services like Postfix (see dedicated section below).

# Stop and disable systemd-resolved
sudo systemctl stop systemd-resolved
sudo systemctl disable systemd-resolved

# Mask to prevent it from ever starting again
sudo systemctl mask systemd-resolved

# Verify it's properly disabled
systemctl status systemd-resolved
# Should show: Loaded: masked (Reason: Unit systemd-resolved.service is masked.)

Why mask instead of just disable?

The mask command creates a symlink to /dev/null, which prevents the service from being started by any means—manually, as a dependency, or automatically. This is stronger than disable and ensures systemd-resolved stays permanently off.

Step 4: Disable unbound-resolvconf Service

During installation, the Debian unbound package automatically enables unbound-resolvconf.service. This service can interfere with our configuration, so disable it:

# Disable the service
sudo systemctl disable --now unbound-resolvconf.service

# Verify it's disabled
systemctl is-enabled unbound-resolvconf.service
# Should show: disabled

Why disable this? Even though the service includes a conditional check and won’t activate without the resolvconf package, disabling it keeps your configuration clean and prevents potential future conflicts.

Step 5: Create Static resolv.conf

Remove the systemd-resolved symlink and create a static /etc/resolv.conf:

# Remove the systemd-resolved symlink
sudo rm -f /etc/resolv.conf

# Create proper resolv.conf pointing to Unbound
sudo tee /etc/resolv.conf > /dev/null << 'EOF'
nameserver 127.0.0.1
nameserver ::1
EOF

# Set proper permissions
sudo chmod 644 /etc/resolv.conf

Make it immutable (bare metal/VMs):

On physical servers and VMs, use the immutable flag to prevent any process from modifying /etc/resolv.conf:

# Make the file immutable
sudo chattr +i /etc/resolv.conf

# Verify the immutable flag
lsattr /etc/resolv.conf
# Should show: ----i---------e------- /etc/resolv.conf

To edit later (if needed):

# Remove immutable flag
sudo chattr -i /etc/resolv.conf

# Make changes
sudo vi /etc/resolv.conf

# Restore immutable flag
sudo chattr +i /etc/resolv.conf

For Incus/LXC containers:

The chattr +i command requires the CAP_LINUX_IMMUTABLE capability, which unprivileged containers don’t have. You’ll get “Operation not permitted” errors.

Container solution – use systemd-networkd configuration instead:

sudo vi /etc/systemd/network/10-eth0.network

Add:

[Match]
Name=eth0

[Network]
DHCP=yes

[DHCP]
UseDNS=false
UseDomains=false

Then enable and restart systemd-networkd:

sudo systemctl enable systemd-networkd
sudo systemctl restart systemd-networkd

What this does: Tells systemd-networkd to ignore DNS servers provided by DHCP, preventing /etc/resolv.conf from being overwritten even without the immutable flag.

Step 6: Configure Unbound Daemon Options

Create the Unbound defaults file to prevent environment variable warnings:

sudo vi /etc/default/unbound

Add:

# Additional daemon options
DAEMON_OPTS=""

This prevents systemd warnings about unset environment variables. Leave it empty for standard operation, or add options later if needed (e.g., DAEMON_OPTS="-d -v" for debugging).

Step 7: Validate and Start Unbound

# Validate configuration
sudo unbound-checkconf

# Start Unbound
sudo systemctl restart unbound
sudo systemctl enable unbound

# Check status
sudo systemctl status unbound
# Should show: active (running)

Expected startup messages:

When Unbound starts, you’ll see informational notices in the logs:

notice: init module 0: validator
notice: init module 1: iterator

These are normal and confirm that:

  • validator module is loaded (handles DNSSEC validation)
  • iterator module is loaded (performs recursive DNS resolution)

The modules load in this order: validator checks DNSSEC signatures first, then iterator does the actual DNS resolution.

Verification

Basic DNS Resolution Tests

# Test direct query to Unbound
dig @127.0.0.1 debian.org

# Test system resolution
ping -c 2 google.com

# Verify resolv.conf
cat /etc/resolv.conf
# Should show: nameserver 127.0.0.1

# Check ownership
ls -la /etc/resolv.conf
# Should show: -rw-r--r-- 1 root root

# For bare metal/VMs, verify immutable flag
lsattr /etc/resolv.conf
# Should show: ----i---------e-------

Test DNS Caching

# First query (slower - fetches from authoritative nameserver)
time dig @127.0.0.1 wikipedia.org

# Second query (faster - served from cache)
time dig @127.0.0.1 wikipedia.org

The second query should be significantly faster (typically <10ms vs 50-200ms).

Test DNSSEC Validation

# Test valid DNSSEC - should resolve successfully
dig +dnssec @127.0.0.1 sigok.verteiltesysteme.net

# Test broken DNSSEC - should fail (SERVFAIL)
dig +dnssec @127.0.0.1 sigfail.verteiltesysteme.net

# Check a common signed domain
dig +dnssec @127.0.0.1 debian.org

Expected results:

  • sigok.verteiltesysteme.net – Returns answer with ad flag (authenticated data)
  • sigfail.verteiltesysteme.net – Returns SERVFAIL (validation failed)
  • If both work correctly, DNSSEC validation is functioning

Monitor Unbound Activity

With log-queries: yes enabled, you can watch DNS queries in real-time:

# Watch logs
sudo journalctl -u unbound -f

# In another terminal, make queries
dig cloudflare.com

You should see log entries like:

info: 127.0.0.1 cloudflare.com. A IN

Disable query logging after testing:

For production use, disable query logging to protect privacy and reduce log volume:

sudo vi /etc/unbound/unbound.conf.d/local.conf
# Change: log-queries: yes to log-queries: no
sudo systemctl restart unbound

Postfix and the systemd-resolved Incompatibility

If you’re running Postfix (or any mail server), this section is critical.

Why systemd-resolved Breaks Postfix

Postfix runs in a chroot environment (/var/spool/postfix/) by default on Debian for security. This chroot needs its own copy of /etc/resolv.conf at /var/spool/postfix/etc/resolv.conf, and this file must be owned by root:root for Postfix to function.

The Problem:

Postfix includes a resolvconf hook at /etc/resolvconf/update-libc.d/postfix that automatically copies /etc/resolv.conf to the chroot:

cat /etc/resolvconf/update-libc.d/postfix
# Shows: [ lo != "$IFACE" ] && cp -pLu /etc/resolv.conf /var/spool/postfix/etc/resolv.conf 2>/dev/null || :

When systemd-resolved is active:

  1. /etc/resolv.conf is a symlink to /run/systemd/resolve/stub-resolv.conf
  2. That file is owned by systemd-resolve:systemd-resolve
  3. The hook uses cp -pL which preserves ownership when copying
  4. Result: /var/spool/postfix/etc/resolv.conf becomes owned by systemd-resolve:systemd-resolve
  5. Postfix fails with ownership error

The Insidious Intermittent Trap:

The hook uses the -u flag (“update only if source is newer”), which creates an intermittent failure mode:

  • Most of the time: Source file isn’t newer, so cp -u doesn’t copy anything
  • Postfix keeps working with the old root:root file
  • After system events: Reboot, network change, systemd-resolved restart
  • Source becomes newer, hook copies it with wrong ownership
  • Postfix suddenly breaks!

This intermittent behavior makes the problem extremely hard to diagnose. Your mail server may work perfectly for weeks, then fail unpredictably after a routine system event.

Demonstration:

# With systemd-resolved active (DON'T do this on production!)
ls -la /etc/resolv.conf
# Shows: lrwxrwxrwx ... /etc/resolv.conf -> ../run/systemd/resolve/stub-resolv.conf

ls -la /run/systemd/resolve/stub-resolv.conf  
# Shows: -rw-r--r-- 1 systemd-resolve systemd-resolve ...

# Trigger the trap (simulates when source is newer)
sudo cp -pL /etc/resolv.conf /var/spool/postfix/etc/resolv.conf

# Check ownership - BROKEN!
ls -la /var/spool/postfix/etc/resolv.conf
# Shows: -rw-r--r-- 1 systemd-resolve systemd-resolve ...

# Postfix detects the problem
postfix check
# Shows: postfix/postfix-script: warning: not owned by root: /var/spool/postfix/etc/resolv.conf

Verify Postfix Chroot Configuration

After disabling systemd-resolved and configuring Unbound, verify the chroot resolv.conf has correct ownership:

# Check if the file exists with correct ownership
ls -la /var/spool/postfix/etc/resolv.conf
# Should show: -rw-r--r-- 1 root root

If the file doesn’t exist yet, Postfix will copy it on next restart:

sudo systemctl restart postfix
ls -la /var/spool/postfix/etc/resolv.conf
# Should now show: -rw-r--r-- 1 root root

Verify Postfix is happy:

postfix check
# Should return with no warnings

Test DNSBL Resolution

With Unbound configured, test that Postfix can query DNSBL services:

# Test a known-listed IP (127.0.0.2 is standard DNSBL test)
dig @127.0.0.1 2.0.0.127.zen.spamhaus.org

# Should return 127.0.0.2 or similar listing code

Note: DNSBL queries require reversed IP octets. For IP 203.0.113.50, query 50.113.0.203.zen.spamhaus.org

Verify Postfix DNS configuration:

postconf smtp_host_lookup
# Should show: smtp_host_lookup = dns

Why this matters: With Unbound, your mail server can now perform unlimited DNSBL queries without rate limiting from public DNS providers. This is essential for effective spam filtering.

Maintenance

Automatic Updates with systemd Timer

Keep root hints and DNSSEC trust anchors updated automatically.

Create update script:

sudo tee /usr/local/bin/update-unbound-data.sh > /dev/null <<'EOF'
#!/bin/bash
set -e

echo "$(date): Updating Unbound data..."

# Update DNSSEC root trust anchor
/usr/sbin/unbound-anchor -a /var/lib/unbound/root.key

# Update root hints
curl -s -o /tmp/root.hints.tmp https://www.internic.net/domain/named.root
if [ -s /tmp/root.hints.tmp ]; then
    mv /tmp/root.hints.tmp /var/lib/unbound/root.hints
    chown unbound:unbound /var/lib/unbound/root.hints
    chmod 644 /var/lib/unbound/root.hints
else
    rm -f /tmp/root.hints.tmp
    exit 1
fi

# Reload Unbound
systemctl reload unbound

echo "$(date): Update completed"
EOF

sudo chmod +x /usr/local/bin/update-unbound-data.sh

Create systemd service:

sudo tee /etc/systemd/system/unbound-update.service > /dev/null <<'EOF'
[Unit]
Description=Update Unbound root hints and trust anchor
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/update-unbound-data.sh
EOF

Create systemd timer:

sudo tee /etc/systemd/system/unbound-update.timer > /dev/null <<'EOF'
[Unit]
Description=Weekly Unbound data update

[Timer]
OnCalendar=weekly
Persistent=true
RandomizedDelaySec=1h

[Install]
WantedBy=timers.target
EOF

Enable the timer:

sudo systemctl daemon-reload
sudo systemctl enable --now unbound-update.timer

# Check timer status
sudo systemctl list-timers unbound-update.timer

# Test manually
sudo systemctl start unbound-update.service

Performance Tuning for High-Volume Servers

For mail servers or other high-volume DNS use, add these performance optimizations to /etc/unbound/unbound.conf.d/local.conf:

server:
    # Increase threads (set to number of CPU cores)
    num-threads: 4
    
    # Larger caches for high volume
    msg-cache-size: 128m
    rrset-cache-size: 256m
    
    # Serve expired entries while fetching fresh data
    serve-expired: yes
    serve-expired-ttl: 3600
    
    # Faster timeout for unresponsive servers
    jostle-timeout: 200
    
    # Increase socket buffer (prevents dropped packets under load)
    so-rcvbuf: 1m

After making changes:

sudo unbound-checkconf
sudo systemctl restart unbound

Note about so-rcvbuf: You may see a warning about the buffer size not being granted. To fix:

# Check current limit
sudo sysctl net.core.rmem_max

# Set higher limit
sudo sysctl -w net.core.rmem_max=1048576

# Make permanent
echo "net.core.rmem_max=1048576" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p

Troubleshooting

DNS Not Resolving

# Check Unbound status
sudo systemctl status unbound

# Check for errors
sudo journalctl -u unbound -n 50

# Test direct query
dig @127.0.0.1 example.com

# Check resolv.conf
cat /etc/resolv.conf
# Should show: nameserver 127.0.0.1

systemd-resolved Keeps Starting

If systemd-resolved keeps restarting despite being disabled:

# Verify the mask
systemctl status systemd-resolved
# Should show: Loaded: masked

# If not masked, mask it
sudo systemctl mask systemd-resolved

# Check for socket activation
systemctl list-sockets | grep resolved
sudo systemctl mask systemd-resolved.socket

Postfix Can’t Resolve DNS

# Check chroot resolv.conf ownership
ls -la /var/spool/postfix/etc/resolv.conf
# Must show: -rw-r--r-- 1 root root

# If wrong ownership, fix it
sudo cp /etc/resolv.conf /var/spool/postfix/etc/resolv.conf
sudo chown root:root /var/spool/postfix/etc/resolv.conf
sudo chmod 644 /var/spool/postfix/etc/resolv.conf

# Restart Postfix
sudo systemctl restart postfix

# Verify
postfix check

Verify Recursive Resolution

Ensure Unbound is doing recursive resolution (not forwarding):

# Should return nothing or only commented lines
sudo grep -r "forward-zone" /etc/unbound/

# Verify root hints is configured
grep "root-hints" /etc/unbound/unbound.conf.d/local.conf

# Test with trace to see full resolution path
dig +trace @127.0.0.1 debian.org

resolv.conf Gets Overwritten (Containers)

If /etc/resolv.conf keeps getting overwritten in containers:

# Verify systemd-networkd configuration exists
cat /etc/systemd/network/10-eth0.network
# Should show: UseDNS=false

# If missing, create it (see Step 5)
# Then restart
sudo systemctl restart systemd-networkd

# Check if DHCP client is overriding
ps aux | grep dhc
# If dhclient is running, it may need configuration

IPv6 Considerations for Servers

This configuration disables IPv6 DNS queries (do-ip6: no) by default. Here’s why:

Spamhaus DNSBL IPv6 Issue:

  • Spamhaus and some DNSBL providers have issues with IPv6 DNS queries
  • They may falsely flag your resolver as an “open resolver”
  • This can block your mail server from using their DNSBL services
  • Since DNSBL queries are essential for spam filtering, IPv6 is disabled

Only enable IPv6 if:

  • You’ve verified all your DNSBL providers support IPv6 queries properly
  • You’ve tested and confirmed no false “open resolver” detections
  • You have a specific requirement for IPv6 DNS resolution

Important: This is about DNS queries Unbound makes, not about your mail server sending/receiving mail over IPv6 (which is configured separately in Postfix).

To enable IPv6 later:

sudo vi /etc/unbound/unbound.conf.d/local.conf
# Change: do-ip6: no to do-ip6: yes
sudo unbound-checkconf
sudo systemctl restart unbound

Summary

You now have a fully functional recursive DNS resolver configured properly for server environments:

What You’ve Accomplished:

  • Direct recursive DNS resolution from root servers
  • No rate limiting from public DNS providers
  • Privacy-focused (no external query logging)
  • DNSSEC validation for security
  • Excellent caching performance
  • Unlimited DNSBL queries for mail servers
  • Automatic updates via systemd timers
  • Postfix-safe configuration (correct ownership)

Why This Configuration:

This guide uses a clean, direct approach: Unbound handles all DNS resolution without systemd-resolved adding an unnecessary layer. For servers—especially mail servers—this is the only safe configuration:

  • Simpler: One DNS resolver, not two
  • Reliable: No intermittent ownership issues
  • Safe for Postfix: Maintains correct file ownership in chroot
  • Better performance: No additional hop through systemd-resolved
  • Easier to troubleshoot: Clear, direct resolution path

Your server can now perform DNS resolution independently, with full control over caching, validation, and query volume. For mail servers, this means unlimited DNSBL lookups without rate limiting or ownership problems.

Similar Posts