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-upgradeapt -y autoremoveapt 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/chronynot 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-01nano /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 chronychronyc 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 fail2banfail2ban-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.dnano /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-reloadsystemctl enable unboundsystemctl 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@853directive is required. - Setting
tls-portalone 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.shchmod +x /etc/letsencrypt/renewal-hooks/pre/ufw-open-80.shchmod +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)
