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.