Introduction
DNS-over-HTTPS (DoH, RFC 8484) encrypts DNS queries within HTTPS traffic on port 443. Unlike traditional DNS (plaintext UDP port 53) and DNS-over-TLS (dedicated port 853), DoH hides DNS queries inside normal HTTPS traffic, making them indistinguishable from web browsing. This prevents ISPs from logging DNS queries and blocks DNS manipulation by network intermediaries.
This guide covers a Mermaid comparison of plain DNS vs DoH resolution flows, Nginx DoH server configuration with TLS 1.3, a Python client that sends RFC 8484 DNS queries to any DoH provider, systemd-resolved and Firefox DoH setup, and enterprise deployment architecture with internal DoH servers.
How DoH Works: Resolution Flow
sequenceDiagram
participant B as Browser/App
participant DoH as DoH Server (1.1.1.1)
participant Resolver as Upstream DNS (Root/TLD)
Note over B,Resolver: Traditional DNS (plaintext)
B->>Resolver: UDP query for example.com
Resolver-->>B: UDP response with IP
Note over B,Resolver: Visible to ISP, anyone on network
Note over B,DoH: DNS-over-HTTPS (encrypted)
B->>DoH: HTTPS POST /dns-query
Note over B,DoH: TLS 1.3 encrypted
DoH->>Resolver: Upstream resolution (encrypted)
Resolver-->>DoH: IP response
DoH-->>B: HTTPS 200 + DNS response
Note over B,DoH: ISP sees: HTTPS to 1.1.1.1:443
Note over B,DoH: ISP cannot see: which domain was queried
Protocol Comparison
| Aspect | Traditional DNS | DoT (RFC 7858) | DoH (RFC 8484) |
|---|---|---|---|
| Port | UDP 53 | TCP 853 | TCP 443 |
| Encryption | None | TLS 1.3 | TLS 1.3 |
| Visibility | Plaintext to ISP | Encrypted, but port 853 is identifiable | Encrypted, looks like HTTPS |
| Blocking risk | Easy to block | Easy to block (port 853) | Hard to block (port 443) |
| 0-RTT | No | No | TLS 1.3 0-RTT |
| Standard | RFC 1035 | RFC 7858 | RFC 8484 |
Nginx DoH Server
Configure Nginx as a DoH endpoint that proxies DNS-over-HTTPS requests to a local DNS resolver:
# /etc/nginx/sites-available/doh
server {
listen 443 ssl http2;
server_name dns.example.com;
# TLS 1.3 only for minimum latency
ssl_protocols TLSv1.3;
ssl_ciphers TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;
ssl_certificate /etc/ssl/certs/dns.example.com.crt;
ssl_certificate_key /etc/ssl/private/dns.example.com.key;
location = /dns-query {
# Only POST method per RFC 8484
limit_except POST { deny all; }
# Proxy to local DNS resolver (Unbound, CoreDNS)
proxy_pass http://127.0.0.1:53;
proxy_set_header X-Forwarded-For $remote_addr;
# DoH requires specific content type
proxy_set_header Content-Type application/dns-message;
# Large enough for DNS responses (max ~4K)
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
# Health check endpoint
location = /health { return 200 "OK"; }
}
Python DoH Client
Send DNS queries to any DoH endpoint using RFC 8484 wire format over HTTPS:
import dns.message
import dns.rdatatype
import httpx
class DoHClient:
"""DNS-over-HTTPS client using RFC 8484 wire format.
Encodes DNS queries as binary DNS wire format, sends them
via HTTPS POST with content-type application/dns-message,
and decodes the response.
"""
PROVIDERS = {
"cloudflare": "https://1.1.1.1/dns-query",
"google": "https://dns.google/dns-query",
"quad9": "https://dns.quad9.net/dns-query",
}
def __init__(self, provider: str = "cloudflare"):
self.url = self.PROVIDERS.get(provider, provider)
self.client = httpx.Client(http2=True) # HTTP/2 for efficiency
def resolve(self, domain: str, record_type: str = "A") -> list:
"""Resolve a DNS record via DoH.
Args:
domain: Domain to query (e.g., 'example.com')
record_type: DNS record type ('A', 'AAAA', 'MX', etc.)
Returns:
List of answer strings (e.g., ['93.184.216.34'])
Raises:
httpx.HTTPError: If the DoH server is unreachable
dns.exception.DNSException: If the DNS response is malformed
"""
# Build DNS query in wire format
query = dns.message.make_query(domain, dns.rdatatype.from_text(record_type))
wire_query = query.to_wire()
# Send via HTTPS POST (RFC 8484 Section 4.1.1)
response = self.client.post(
self.url,
content=wire_query,
headers={"Content-Type": "application/dns-message"},
timeout=10.0
)
response.raise_for_status()
# Parse DNS response from wire format
answer = dns.message.from_wire(response.content)
results = []
for rrset in answer.answer:
for rdata in rrset:
results.append(str(rdata))
return results
# Usage
client = DoHClient("cloudflare")
# A record lookup
ips = client.resolve("example.com", "A")
print(f"example.com A records: {ips}") # ['93.184.216.34']
# MX record lookup
mx = client.resolve("gmail.com", "MX")
print(f"gmail.com MX records: {mx}") # ['5 gmail-smtp-in.l.google.com.', ...]
Client Configuration
Linux (systemd-resolved)
# /etc/systemd/resolved.conf
[Resolve]
DNS=1.1.1.1 1.0.0.1
DNSOverHTTPS=yes
DNSSEC=yes
sudo systemctl restart systemd-resolved
# Verify
resolvectl status
Firefox
about:config → network.trr.mode = 3 (Force DoH)
about:config → network.trr.uri = https://1.1.1.1/dns-query
Enterprise Architecture
flowchart LR
subgraph Clients["Internal Clients"]
C1[Workstation]
C2[Mobile]
C3[Server]
end
subgraph Enterprise["Enterprise Network"]
DoH[Internal DoH Server<br/>Nginx + Unbound]
Auth[Authentication<br/>Client cert / IP allowlist]
Log[DNS Audit Log]
Filter[DNS Filtering<br/>Policy enforcement]
end
subgraph Upstream["Upstream Resolvers"]
CF[Cloudflare 1.1.1.1]
G[Google 8.8.8.8]
Q9[Quad9 9.9.9.9]
end
C1 -->|HTTPS:443| DoH
C2 -->|HTTPS:443| DoH
C3 -->|HTTPS:443| DoH
DoH --> Auth --> Log --> Filter
Filter --> CF
Filter --> G
Filter --> Q9
Resources
- RFC 8484 — DNS Queries over HTTPS (DoH)
- RFC 7858 — DNS over TLS (DoT)
- dnspython Documentation — Python DNS toolkit
- httpx HTTP/2 Client — Async HTTP client for DoH
- systemd-resolved DoH — Linux DoH configuration
Comments