Unbound Recursive DNS (Ubuntu)

Notes:

  • Unbound Website: NLnet Labs – Unbound – About
  • Public recursive DNS resolver using Unbound on Ubuntu 24.04.
  • This server provides validated DNS recursion for clients and is intentionally not an authoritative DNS server.
  • This is installed in my homelab environment, not a production environment.
  • I’m assuming this will work on other Ubuntu distributions as well, but I have not tested.
  • Known Pitfalls (for reference)
    • systemd-resolved must be disabled
    • Unbound binds to loopback by default on Ubuntu
    • systemd IPAddress sandboxing blocks non-local binds unless overridden
    • DNSSEC requires /var/lib/unbound to be writable
    • systemd unit changes require a service restart, not just enable

VM Settings

  • 1vCPU
  • 2GB Memory
  • 40GB of Disk, Thin Provision
  • Ubuntu 24.04 LTS (Minimal installation)
  • The IP address I will be using is 10.0.30.191.
  • Replace this with your server IP.
  • The Hostname and Records I’ll be using are:
  • ns1.rustysdatabase.com

Server Setup

1. Update Server/Cleanup.

  • apt update && apt -y full-upgrade
  • apt -y autoremove
  • apt clean

2. Install required and useful packages

apt install -y \
  nano \
  ufw fail2ban unattended-upgrades apt-listchanges \
  chrony rsyslog logrotate \
  unbound dnsutils tcpdump
  • During chrony installation, dpkg may emit a warning about /var/log/chrony not existing. This is a harmless packaging warning and does not affect chrony operation. The directory is created automatically at runtime or can be pre-created to silence the warning.

3. Set hostname and verify

  • hostnamectl set-hostname rec-dns-01
  • nano /etc/hosts
127.0.0.1       localhost
127.0.1.1       rec-dns-01
  • This prevents sudo warnings and ensures local hostname resolution works even without DNS.

4. Enable Time Sync for DNSSEC + Verify

  • systemctl enable --now chrony
  • chronyc tracking

5. Disable systemd-resolved

  • systemctl disable --now systemd-resolved
  • systemd-resolved must be disabled or it will conflict with Unbound binding to port 53

6. Fix the server resolver

rm -f /etc/resolv.conf
tee /etc/resolv.conf <<'EOF'
nameserver 208.67.222.222
nameserver 208.67.220.220
options edns0 trust-ad
EOF
  • Change the nameservers to any public DNS.
  • The DNS server itself is intentionally configured to use external resolvers in /etc/resolv.conf. This avoids boot‑time dependency loops and ensures system services remain functional even if Unbound is unavailable.

7. SSH Hardening (Allows password login)

  • nano /etc/ssh/sshd_config
    • Replace AllowUsers with anyone allowed to login with a password.
PermitRootLogin no
PasswordAuthentication yes
KbdInteractiveAuthentication yes
AllowUsers rusty
     
MaxAuthTries 3
MaxSessions 2
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
  • systemctl restart ssh

8. Configure Firewall (UFW)

ufw --force reset
ufw default deny incoming
ufw default allow outgoing

ufw allow from 10.0.0.0/8 to any port 22 proto tcp comment 'SSH mgmt (RFC1918)'
ufw allow from 172.16.0.0/12 to any port 22 proto tcp comment 'SSH mgmt (RFC1918)'
ufw allow from 192.168.0.0/16 to any port 22 proto tcp comment 'SSH mgmt (RFC1918)'

ufw allow 53/udp comment 'Public DNS (UDP/53)'
ufw allow 53/tcp comment 'Public DNS (TCP/53)'

ufw enable
ufw status verbose

9. Enable Fail2Ban for SSH protection

  • systemctl enable --now fail2ban
  • fail2ban-client status sshd

10. Kernel Hardening + Apply it to the system

  • nano /etc/sysctl.d/99-dns-hardening.conf
# Reverse path filtering
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1

# Basic hygiene
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1

# Disable redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
  • sysctl --system

11. Unbound Configuration + Validation

  • Ubuntu provides DNSSEC trust anchor configuration by default. Do not manually add auto-trust-anchor-file directives.
  • nano /etc/unbound/unbound.conf.d/recursive.conf
server:
  interface: 0.0.0.0
  port: 53

  do-ip4: yes
  do-ip6: no
  do-udp: yes
  do-tcp: yes

  # ----- ACCESS CONTROL -----
  # Localhost
  access-control: 127.0.0.0/8 allow
  access-control: ::1 allow
  # Replace RFC1918 with CUSTOMER PUBLIC NETBLOCKS before production
  access-control: 10.0.0.0/8 allow
  access-control: 172.16.0.0/12 allow
  access-control: 192.168.0.0/16 allow

  access-control: 0.0.0.0/0 deny
  access-control: ::0/0 deny

  # ----- PRIVACY / HARDENING -----
  hide-identity: yes
  hide-version: yes
  qname-minimisation: yes
  harden-dnssec-stripped: yes

  # DNS Flag Day sizing
  edns-buffer-size: 1232
  max-udp-size: 1232

  # Performance / abuse resistance
  num-threads: 2
  so-reuseport: yes
  outgoing-range: 4096
  num-queries-per-thread: 2048
  jostle-timeout: 200
  wait-limit: 1000
  wait-limit-cookie: 10000

  verbosity: 1
  • unbound-checkconf
  • Should see the following
unbound-checkconf: no errors in /etc/unbound/unbound.conf

12. Systemd Hardening

  • mkdir -p /etc/systemd/system/unbound.service.d
  • nano /etc/systemd/system/unbound.service.d/override.conf
[Service]
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes

ProtectSystem=full
ProtectHome=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictNamespaces=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes

# Required for interface enumeration
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK

# REQUIRED: allow DNSSEC trust anchor updates
ReadWritePaths=/var/lib/unbound /run

# CRITICAL: allow binding to non-loopback IPs
IPAddressDeny=
IPAddressAllow=any
  • systemctl daemon-reload
  • systemctl enable unbound
  • systemctl restart unbound
  • Ubuntu applies IPAddress sandboxing to Unbound by default, which restricts it to loopback only.
  • IPAddressDeny must be cleared and IPAddressAllow set to allow binding to non-loopback interfaces.

13. Verify Binding

  • ss -lntup | grep ':53'
  • Output should be
  udp   UNCONN 0      0            0.0.0.0:53        0.0.0.0:*    users:(("unbound",pid=3696,fd=5))
  udp   UNCONN 0      0            0.0.0.0:53        0.0.0.0:*    users:(("unbound",pid=3696,fd=3))
  tcp   LISTEN 0      256          0.0.0.0:53        0.0.0.0:*    users:(("unbound",pid=3696,fd=6))
  tcp   LISTEN 0      256          0.0.0.0:53        0.0.0.0:*    users:(("unbound",pid=3696,fd=4))
  • The important part is Unbound is binding to 0.0.0.0, if it’s 127.0.0.1, that’s incorrect.

14. Testing

  • dig @10.0.30.191 rustysdatabase.com
  • Should see NOERROR in the output.
  • dig @10.0.30.191 dnssec-failed.org A +dnssec
  • Should see SERVFAIL in the output.
  • dig @localhost rustysdatabase.com
  • Should return the same result as querying the server IP

DNS over TLS (DoT) Implementation

This section adds DNS over TLS (DoT) to an already functional recursive DNS server.
DoT is intentionally implemented after plain DNS and DNSSEC validation are confirmed working.

Prerequisites

  • Server must be publicly reachable.
  • Required inbound ports:
    • 80/TCP – Temporary (Certbot HTTP‑01 challenge)
    • 53/TCP+UDP – DNS
    • 853/TCP – DNS over TLS
  • Public DNS A record:
    • Mine is ns1.rustysdatabase.com

1. Allow DNS over TLS through the firewall

ufw allow 80/tcp comment 'Certbot HTTP-01 challenge'
ufw allow 853/tcp comment 'DNS over TLS'

2. Install Certbot and obtain certificate

  • apt -y install certbot
    • Request a certificate using standalone mode
certbot certonly --standalone \
  -d ns1.rustysdatabase.com \
  --preferred-challenges http
  • Certificates are stored at
/etc/letsencrypt/live/ns1.rustysdatabase.com/
  ├─ fullchain.pem
  └─ privkey.pem

3. Allow Unbound to read the certificates

  • Ubuntu applies strict systemd sandboxing to Unbound.
  • Explicit read‑only access to the Let’s Encrypt directory is required.
  • Edit the systemd override.
  • nano /etc/systemd/system/unbound.service.d/override.conf
#DNS over TLS Certificates
ReadOnlyPaths=/etc/letsencrypt
  • Reload systemd configuration.
  • systemctl daemon-reload

4. Allow Unbound to load the Certificates

  • AppArmor blocks access to private keys by default and must be explicitly extended.
  • nano /etc/apparmor.d/local/usr.sbin.unbound
/etc/letsencrypt/** r,
  • Reload the AppArmor profile
  • apparmor_parser -r /etc/apparmor.d/usr.sbin.unbound

5. Enable DNS over TLS in Unbound

  • Edit the Unbound configuration
  • nano /etc/unbound/unbound.conf.d/recursive.conf
  # DNS-over-TLS
  interface: 0.0.0.0@853
  tls-port: 853
  tls-service-key: "/etc/letsencrypt/live/ns1.rustysdatabase.com/privkey.pem"
  tls-service-pem: "/etc/letsencrypt/live/ns1.rustysdatabase.com/fullchain.pem"
  • The interface: 0.0.0.0@853 directive is required.
  • Setting tls-port alone does not cause Unbound to listen on port 853.
  • Validate the unbound config
    • unbound-checkconf

6. Restart Unbound

  • Restart Unbound
  • systemctl restart unbound
    • Verify Status
  • systemctl status unbound --no-pager
    • Verify Listening Socket
  • ss -lntp | grep 853
    • Expected otuput
LISTEN 0 256 0.0.0.0:853 users:(("unbound",pid=...))

7. Auto-reload Unbound on cert renewal

  • Create deploy hook directory
  • mkdir -p /etc/letsencrypt/renewal-hooks/deploy
  • Create deploy hook
  • nano /etc/letsencrypt/renewal-hooks/deploy/unbound-reload.sh
    • Contents
#!/usr/bin/env bash

logger -t certbot-unbound "Certificate updated, reloading Unbound"

if systemctl reload unbound; then
  logger -t certbot-unbound "Unbound reloaded successfully"
else
  logger -t certbot-unbound "Reload failed, restarting Unbound"
  systemctl restart unbound
  logger -t certbot-unbound "Unbound restarted"
fi
  • Create a hook to temporarily open port 80.
  • nano /etc/letsencrypt/renewal-hooks/pre/ufw-open-80.sh
#!/usr/bin/env bash

# Open TCP/80 temporarily for Let's Encrypt HTTP-01 validation
# This rule is added automatically by Certbot and removed after renewal.

RULE_COMMENT="${LE_HTTP_COMMENT:-Certbot HTTP-01 challenge}"

logger -t certbot-ufw "Opening TCP/80 for Let's Encrypt validation"
ufw allow 80/tcp comment "${RULE_COMMENT}"
  • Create a hook to close port 80.
  • nano /etc/letsencrypt/renewal-hooks/post/ufw-close-80.sh
#!/usr/bin/env bash

RULE_COMMENT="${LE_HTTP_COMMENT:-Certbot HTTP-01 challenge}"

logger -t certbot-ufw "Closing TCP/80 after Let's Encrypt validation"
ufw delete allow 80/tcp
  • Make executable
    • chmod +x /etc/letsencrypt/renewal-hooks/deploy/unbound-reload.sh
    • chmod +x /etc/letsencrypt/renewal-hooks/pre/ufw-open-80.sh
    • chmod +x /etc/letsencrypt/renewal-hooks/post/ufw-close-80.sh
  • Restart the daemon
    • systemctl daemon-reload
  • Test hook
    • certbot renew --force-renewal --staging
  • Verify reload occurred
    • journalctl -u unbound --since "2 minutes ago"
      • systemctl status timestamps and PID will not change on reload.
      • Unbound performs an in‑process reload to apply new certificates without downtime.

8. Testing

  • Verify DNS over TLS
    • dig +tls @ns1.rustysdatabase.com rustysdatabase.com
    • Expected Outpit
Status - NO ERROR
SERVER: <IP>#853 (TLS)
  • Confirms
    • TCP/853 is in use
    • TLS handshake succeeded
    • Certificate is accepted by the client
  • Verify DNSSEC over TLS
    • dig +tls +dnssec @ns1.rustysdatabase.com cloudflare.com
    • Expected Output
status: NOERROR 
flags: qr rd ra ad
  • Confirms:
    • DNSSEC validation occurred
    • ad (Authenticated Data) flag is set
    • Validation happened over TLS, not plain DNS

To Do

Certbot Letsencrypt staging instead of issue cert

Unbound statistics

  • unbound-control stats
  • Grafana dashboard (QPS, cache hit %, DNSSEC failures, TLS usage)

Rate limiting?

  • Per‑subnet or per‑IP limits
  • Protection against amplification abuse

Views

  • RFC1918 clients get recursion
  • Public clients restricted / rate‑limited
  • Different policies per network

DoQ (DNS‑over‑QUIC)