Classic DNS travels in plain text over port 53, so anyone on the path, from a public Wi-Fi operator to an ISP, can see and alter the names you look up. DNS-over-TLS (DoT, RFC 7858) wraps DNS in TLS on TCP port 853, and DNS-over-HTTPS (DoH, RFC 8484) sends DNS messages inside HTTPS requests on port 443. In this tutorial you will turn an Ubuntu 24.04 server into your own encrypted resolver using Unbound alone, which can resolve recursively, validate DNSSEC and serve both DoT and DoH natively, and then connect Linux, Android and browser clients to it.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS with a public IP address, for example a CubePath VPS. The server should not run another web server on port 443, since Unbound will use it for DoH.
- A non-root user with
sudoprivileges and UFW enabled with SSH allowed. - A domain name with an
Arecord pointing to the server. This guide usesdns.example.com; replace it with your own. - Basic knowledge of how DNS resolution works.
How DoT and DoH compare
Both protocols encrypt the same DNS messages; they differ in transport and in how easy they are to detect or block.
| DNS-over-TLS | DNS-over-HTTPS | |
|---|---|---|
| Port | 853/tcp | 443/tcp |
| Transport | TLS | HTTPS (HTTP/2) |
| Visible as DNS on the network | Yes, by its port | No, looks like web traffic |
| Typical clients | Android Private DNS, systemd-resolved, routers | Browsers, Windows 11, iOS and macOS |
Running both from the same resolver covers practically every client.
Step 1 - Installing Unbound and checking DoH support
Install Unbound and dig:
sudo apt update
sudo apt install unbound bind9-dnsutils
Unbound can only serve DNS-over-HTTPS when it is built with the libnghttp2 library. Check that your build includes it:
unbound -V | grep -o 'with-libnghttp2'
with-libnghttp2
If the command prints nothing, your build supports DoT but not DoH; you can still follow the guide and skip the DoH parts.
Unbound starts automatically and listens on 127.0.0.1. Ubuntu's package already enables DNSSEC validation through /etc/unbound/unbound.conf.d/root-auto-trust-anchor-file.conf. Confirm that it resolves:
dig @127.0.0.1 cubepath.com +short
Step 2 - Getting a TLS certificate
Clients verify that the resolver presents a valid certificate for dns.example.com. Install Certbot and open port 80 for the HTTP challenge:
sudo apt install certbot
sudo ufw allow 80/tcp
sudo certbot certonly --standalone -d dns.example.com
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/dns.example.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/dns.example.com/privkey.pem
Ubuntu confines Unbound with AppArmor, which only allows it to read files under /etc/unbound. Rather than weakening that profile, copy the certificate into /etc/unbound/tls with a Certbot deploy hook, which also runs after every renewal:
sudo nano /etc/letsencrypt/renewal-hooks/deploy/unbound-tls.sh
#!/usr/bin/env bash
set -euo pipefail
domain="dns.example.com"
src="/etc/letsencrypt/live/${domain}"
dst="/etc/unbound/tls"
# Only act on the certificate Unbound uses
if [[ "${RENEWED_LINEAGE:-}" != "${src}" ]]; then
exit 0
fi
install -d -m 0750 -o root -g unbound "${dst}"
install -m 0644 -o root -g unbound "${src}/fullchain.pem" "${dst}/fullchain.pem"
install -m 0640 -o root -g unbound "${src}/privkey.pem" "${dst}/privkey.pem"
systemctl restart unbound
Make the hook executable and run it once by hand, passing the variable Certbot would set:
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/unbound-tls.sh
sudo RENEWED_LINEAGE=/etc/letsencrypt/live/dns.example.com /etc/letsencrypt/renewal-hooks/deploy/unbound-tls.sh
sudo ls -l /etc/unbound/tls
-rw-r--r-- 1 root unbound 2868 Sep 25 10:12 fullchain.pem
-rw-r----- 1 root unbound 227 Sep 25 10:12 privkey.pem
Step 3 - Configuring Unbound for DoT and DoH
Create a configuration file for the encrypted listeners:
sudo nano /etc/unbound/unbound.conf.d/encrypted-dns.conf
server:
# Plain DNS only for the server itself
interface: 127.0.0.1
# Encrypted listeners on all IPv4 addresses
interface: 0.0.0.0@853
interface: 0.0.0.0@443
tls-service-key: "/etc/unbound/tls/privkey.pem"
tls-service-pem: "/etc/unbound/tls/fullchain.pem"
tls-port: 853
https-port: 443
http-endpoint: "/dns-query"
# Who may use the resolver through the encrypted ports
access-control: 0.0.0.0/0 allow
# Basic abuse protection: queries per second per client IP
ip-ratelimit: 100
# Cache and privacy
num-threads: 2
msg-cache-size: 64m
rrset-cache-size: 128m
prefetch: yes
hide-identity: yes
hide-version: yes
qname-minimisation: yes
edns-buffer-size: 1232
How this works:
- Unbound decides the protocol by port: interfaces on
tls-portspeak DoT and interfaces onhttps-portspeak DoH at the path set byhttp-endpoint. - Plain port 53 stays bound to
127.0.0.1, so the server is not an open plain-text resolver. Only the encrypted ports are reachable from outside. access-control: 0.0.0.0/0 allowmakes the encrypted resolver usable from any network, which is what mobile devices need. If all your clients have fixed IPs, list only those ranges instead.
Check the syntax and restart:
sudo unbound-checkconf && sudo systemctl restart unbound
sudo ss -lntp | grep unbound
LISTEN 0 256 0.0.0.0:443 0.0.0.0:* users:(("unbound",...))
LISTEN 0 256 0.0.0.0:853 0.0.0.0:* users:(("unbound",...))
LISTEN 0 256 127.0.0.1:53 0.0.0.0:* users:(("unbound",...))
Open the encrypted ports in UFW:
sudo ufw allow 853/tcp
sudo ufw allow 443/tcp
sudo ufw status
Step 4 - Testing DoT and DoH
Test from another machine, so the queries cross the network. For DoT, use kdig from the knot-dnsutils package; +tls-ca validates the certificate against the system CA store:
sudo apt install knot-dnsutils
kdig @dns.example.com +tls-ca +tls-hostname=dns.example.com cubepath.com
;; TLS session (TLS1.3)-(ECDHE-X25519)-(ECDSA-SECP384R1-SHA384)-(AES-256-GCM)
;; ->>HEADER<<- opcode: QUERY; status: NOERROR; id: 11821
...
For DoH, curl can resolve a hostname through a DoH server before fetching a page. If the request succeeds, your DoH endpoint answered:
curl --doh-url https://dns.example.com/dns-query -sI https://example.com | head -n 1
HTTP/2 200
Also confirm that DNSSEC validation works through the encrypted path. This domain has broken signatures and must fail:
kdig @dns.example.com +tls-ca +tls-hostname=dns.example.com dnssec-failed.org | grep status
;; ->>HEADER<<- opcode: QUERY; status: SERVFAIL; id: 3310
Step 5 - Configuring Linux clients with DoT
systemd-resolved, used by Ubuntu, Debian and Fedora desktops, supports DNS-over-TLS but not DoH. Create a drop-in on the client, replacing your_server_ip:
sudo mkdir -p /etc/systemd/resolved.conf.d
sudo nano /etc/systemd/resolved.conf.d/dot.conf
[Resolve]
DNS=your_server_ip#dns.example.com
DNSOverTLS=yes
Domains=~.
The part after # is the name checked against the server's certificate. DNSOverTLS=yes is strict: if the TLS connection fails, resolution fails rather than falling back to plain text. Domains=~. sends all queries to this server instead of the DNS servers received from DHCP.
Restart the service and check the status:
sudo systemctl restart systemd-resolved
resolvectl status | head -n 6
resolvectl query cubepath.com
Global
Protocols: -LLMNR -mDNS +DNSOverTLS DNSSEC=no/unsupported
resolv.conf mode: stub
Current DNS Server: your_server_ip#dns.example.com
To confirm that nothing leaves the client in plain text, watch port 53 on the client's external interface while you browse. The capture should stay empty:
sudo tcpdump -ni any 'port 53 and not host 127.0.0.53'
Step 6 - Configuring browsers, Android and other clients
Use these settings on other devices:
- Android 9 and later: Settings > Network & internet > Private DNS > Private DNS provider hostname:
dns.example.com. - Firefox: Settings > Privacy & Security > DNS over HTTPS > Max Protection > Custom:
https://dns.example.com/dns-query. - Chrome and Edge: Settings > Privacy and security > Security > Use secure DNS > Custom:
https://dns.example.com/dns-query. - Windows 11: add
your_server_ipas DNS server in the network adapter settings, set DNS over HTTPS to On (manual template) and enterhttps://dns.example.com/dns-query.
Step 7 - Monitoring usage
Ubuntu's Unbound package enables unbound-control over a local socket. Check how many queries arrive and how many are answered from cache:
sudo unbound-control stats_noreset | grep -E '^total\.num\.(queries|cachehits)='
total.num.queries=5210
total.num.cachehits=4388
Follow the service log if clients report errors:
sudo journalctl -u unbound -f
Troubleshooting
Unbound fails with error: cannot open ... privkey.pem. The key is not readable by Unbound or the path points to /etc/letsencrypt, which AppArmor blocks. Run the deploy hook from Step 2 again and use the paths under /etc/unbound/tls.
can't bind socket: Address already in use for port 443. Another web server, such as Nginx or Apache, is using port 443. Stop it, or move DoH to a dedicated server.
kdig reports a certificate error. The hostname passed to +tls-hostname must match the certificate. Check the served certificate with openssl s_client -connect dns.example.com:853 -servername dns.example.com </dev/null | openssl x509 -noout -subject -dates.
systemd-resolved stops resolving after enabling DoT. Port 853 is blocked somewhere on the path, or the name after # does not match the certificate. Test with kdig from the same client.
Certificate renewal fails. The standalone challenge needs port 80 open and free. Test with sudo certbot renew --dry-run.
Conclusion
Your Ubuntu 24.04 server now runs a validating, caching resolver that accepts DNS-over-TLS on port 853 and DNS-over-HTTPS on port 443, with certificates that renew and reload automatically. Next, you can put an ad-blocking layer such as AdGuard Home in front of it, restrict access-control to the networks you actually use, or add a second resolver in another location for redundancy.
