DNS-over-HTTPS and DNS-over-TLS Configuration
DNS-over-HTTPS (DoH) and DNS-over-TLS (DoT) encrypt DNS queries to prevent eavesdropping and manipulation, replacing plaintext DNS traffic over port 53. This guide covers setting up server-side encrypted DNS with Nginx and Unbound, configuring Linux clients, integrating with Cloudflare, and applying DNS privacy best practices.
Prerequisites
- Ubuntu 22.04/Debian 12 or CentOS/Rocky 9
- Nginx installed (for DoH/DoT server-side)
- A domain name with a valid TLS certificate (Let's Encrypt)
- Unbound or another resolver for the backend
- Ports 443 (DoH), 853 (DoT) open in firewall
How DoH and DoT Work
- DNS-over-TLS (DoT): DNS queries wrapped in TLS on TCP port 853. Identifiable as DNS traffic by network observers but encrypted.
- DNS-over-HTTPS (DoH): DNS queries encoded as HTTPS requests on port 443. Indistinguishable from regular web traffic.
Both eliminate plaintext DNS queries that reveal browsing destinations to ISPs, network operators, and attackers.
Set Up a DoH Server with Nginx
A DoH server accepts HTTPS requests and forwards them to a local DNS resolver. We use nginx as a reverse proxy and dns-over-https or a DoH-capable resolver as the backend.
Install dns-over-https server (go-based):
# Install Go
sudo apt install -y golang-go
# Build and install dns-over-https
git clone https://github.com/m13253/dns-over-https.git /tmp/doh
cd /tmp/doh
make
sudo make install
Configure the DoH server:
sudo tee /etc/dns-over-https/doh-server.conf << 'EOF'
listen = ["127.0.0.1:8053"]
local_addr = ""
cert = ""
key = ""
path = "/dns-query"
upstream = [
"udp:127.0.0.1:53", # Local Unbound resolver
]
timeout = 10
tries = 3
verbose = false
log_guessed_client_ip = false
EOF
sudo systemctl enable --now doh-server
Configure Nginx to proxy DoH requests over HTTPS:
sudo tee /etc/nginx/sites-available/doh << 'EOF'
server {
listen 443 ssl http2;
server_name dns.example.com;
ssl_certificate /etc/letsencrypt/live/dns.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dns.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# DoH endpoint
location /dns-query {
proxy_pass http://127.0.0.1:8053;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Required headers for DoH
proxy_set_header Content-Type application/dns-message;
# Handle both GET and POST
proxy_method $request_method;
}
# Health check endpoint
location /health {
return 200 "OK\n";
add_header Content-Type text/plain;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name dns.example.com;
return 301 https://$host$request_uri;
}
EOF
sudo ln -s /etc/nginx/sites-available/doh /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Test the DoH server:
# Test with curl (GET method with base64url-encoded DNS query)
DNS_QUERY=$(echo -n '\x00\x00\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x06google\x03com\x00\x00\x01\x00\x01' | base64 | tr '+/' '-_' | tr -d '=')
curl -s "https://dns.example.com/dns-query?dns=${DNS_QUERY}" \
-H "accept: application/dns-message" | xxd | head
# Easier test using kdig from knot-dnsutils
sudo apt install -y knot-dnsutils
kdig -d @dns.example.com +https google.com
Set Up DoT with Nginx Stream Proxy
DNS-over-TLS on port 853 can be proxied using Nginx's stream module:
# Ensure Nginx has the stream module
nginx -V 2>&1 | grep stream
# Add stream block to /etc/nginx/nginx.conf
sudo tee -a /etc/nginx/nginx.conf << 'EOF'
stream {
upstream dns_backend {
server 127.0.0.1:53;
}
server {
listen 853 ssl;
ssl_certificate /etc/letsencrypt/live/dns.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dns.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_handshake_timeout 10s;
proxy_pass dns_backend;
proxy_timeout 10s;
proxy_connect_timeout 5s;
}
}
EOF
sudo nginx -t && sudo systemctl reload nginx
# Test DoT
kdig -d @dns.example.com +tls-ca +tls-host=dns.example.com google.com
Configure Unbound as the Backend Resolver
Unbound handles the actual DNS resolution behind Nginx:
sudo tee /etc/unbound/unbound.conf.d/doh-backend.conf << 'EOF'
server:
interface: 127.0.0.1
port: 53
access-control: 127.0.0.0/8 allow
hide-identity: yes
hide-version: yes
qname-minimisation: yes
harden-glue: yes
harden-dnssec-stripped: yes
prefetch: yes
cache-min-ttl: 60
cache-max-ttl: 86400
auto-trust-anchor-file: "/var/lib/unbound/root.key"
root-hints: "/etc/unbound/root.hints"
EOF
sudo curl -o /etc/unbound/root.hints https://www.internic.net/domain/named.cache
sudo systemctl enable --now unbound
dig @127.0.0.1 google.com # verify
Configure Linux Clients for DoH
Using systemd-resolved with DoH (systemd 247+):
# Check systemd version (needs 247+ for DoH support)
systemd --version
# Configure systemd-resolved for DoH
sudo tee /etc/systemd/resolved.conf.d/doh.conf << 'EOF'
[Resolve]
DNS=https://dns.example.com/dns-query
DNSOverTLS=yes
DNSSEC=yes
EOF
sudo systemctl restart systemd-resolved
resolvectl status
resolvectl query google.com
Using cloudflared (Cloudflare's DoH proxy) on the client:
# Install cloudflared
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb \
-o /tmp/cloudflared.deb
sudo dpkg -i /tmp/cloudflared.deb
# Configure cloudflared as a local DNS proxy
sudo cloudflared service install
sudo tee /etc/cloudflared/config.yml << 'EOF'
proxy-dns: true
proxy-dns-port: 5053
proxy-dns-upstream:
- https://dns.example.com/dns-query # your own server
- https://1.1.1.1/dns-query # Cloudflare fallback
EOF
sudo systemctl enable --now cloudflared
# Point the system resolver to cloudflared
sudo tee /etc/resolv.conf << 'EOF'
nameserver 127.0.0.1
options edns0
EOF
Configure Linux Clients for DoT
Using systemd-resolved for DoT:
sudo tee /etc/systemd/resolved.conf.d/dot.conf << 'EOF'
[Resolve]
DNS=853#dns.example.com
DNSOverTLS=yes
DNSSEC=yes
FallbackDNS=9.9.9.9
EOF
sudo systemctl restart systemd-resolved
# Verify encrypted DNS is being used
resolvectl status
# Should show DNSOverTLS: yes
Using stunnel for DoT on older systems:
sudo apt install -y stunnel4
sudo tee /etc/stunnel/dns-tls.conf << 'EOF'
[dns-over-tls]
client = yes
accept = 127.0.0.1:5300
connect = dns.example.com:853
verifyChain = yes
CApath = /etc/ssl/certs
checkHost = dns.example.com
EOF
sudo systemctl enable --now stunnel4
# Use 127.0.0.1:5300 as the DNS resolver
dig @127.0.0.1 -p 5300 google.com
Cloudflare DoH Integration
Use Cloudflare's public DoH service as an upstream resolver:
# Test Cloudflare DoH directly
curl -s "https://1.1.1.1/dns-query?name=example.com&type=A" \
-H "accept: application/dns-json" | python3 -m json.tool
# Configure Unbound to use Cloudflare DoH as upstream
# Unbound doesn't natively support DoH, so use DNS-over-TLS instead:
sudo tee /etc/unbound/unbound.conf.d/cloudflare.conf << 'EOF'
forward-zone:
name: "."
forward-tls-upstream: yes
forward-addr: 1.1.1.1@853#cloudflare-dns.com
forward-addr: 1.0.0.1@853#cloudflare-dns.com
EOF
sudo systemctl restart unbound
# Capture traffic to verify TLS is being used (no port 53 traffic to Cloudflare)
sudo tcpdump -i any 'host 1.1.1.1 and port 853' -n
Troubleshooting
DoH requests returning 502:
# Check the doh-server backend is running
sudo systemctl status doh-server
curl http://127.0.0.1:8053/dns-query # direct test
journalctl -u doh-server -n 50 --no-pager
DoT certificate errors:
# Verify the certificate matches the hostname
openssl s_client -connect dns.example.com:853 -servername dns.example.com
# Check certificate expiry
openssl s_client -connect dns.example.com:853 2>/dev/null \
| openssl x509 -noout -dates
Clients not using encrypted DNS:
# Capture plaintext DNS traffic - should see nothing to external IPs on port 53
sudo tcpdump -i eth0 'port 53 and not src host 127.0.0.1' -n
# Verify systemd-resolved is using DoT
resolvectl statistics
High latency on first query:
# DoT/DoH have TLS handshake overhead for the first connection
# Persistent connections reduce this - ensure keep-alive is configured in Nginx:
# keepalive_timeout 60s;
# keepalive_requests 1000;
# Measure actual latency
time kdig @dns.example.com +https google.com
Conclusion
DNS-over-HTTPS and DNS-over-TLS protect DNS queries from interception and tampering, ensuring that browsing destinations remain private between the client and the trusted resolver. Running your own DoH/DoT server on a VPS gives you full control over query logging and upstream resolver selection, eliminating dependence on third-party DNS providers. Use systemd-resolved with DNSOverTLS=yes for simple client-side encrypted DNS on modern Linux systems, falling back to cloudflared or stunnel for older distributions.


