Implementing DNS over HTTPS (DoH) with BIND and nginx
First, a short side note.
Personal viewpoint: I’m not a fan of DNS over HTTPS (DoH). The main reason is not encryption itself—encryption is a good thing—but how DoH is implemented and deployed.
Facts: DoH encapsulates DNS queries inside regular HTTPS traffic (TCP/443), making DNS traffic indistinguishable from normal web traffic at the network level. This design choice means traditional DNS controls—such as firewall rules, DNS logging, traffic inspection, or policy enforcement—become significantly harder or outright impossible without deep TLS interception. In contrast, DNS over TLS (DoT) uses a dedicated port (TCP/853), which makes it far easier to identify, monitor, rate-limit, or block at the network edge while still providing full encryption.
Facts: Major public DoH providers include Google, Cloudflare, and others. When clients use these services, DNS resolution is effectively outsourced to third parties, centralizing large volumes of metadata such as query timing, source IPs (or at least coarse location), and domain access patterns. Even when providers claim privacy protections, DNS data remains extremely valuable for analytics, traffic profiling, and behavioral insights.
Personal viewpoint: This centralization is exactly what I’m uncomfortable with. Large data-driven companies have a clear incentive to collect as much metadata as possible, and DoH makes that process easier by bypassing local resolvers and enterprise or ISP-level controls. DoT, on the other hand, still allows organizations to run their own encrypted resolvers without losing visibility or policy enforcement.
Facts: From an operational and security standpoint, DoH also breaks several long-standing DNS mechanisms:
- It bypasses split-horizon DNS designs.
- It ignores local DNS policy tools such as RPZ, DNS firewalling, or internal blocklists.
- It complicates incident response and troubleshooting, since DNS activity is hidden inside generic HTTPS flows.
Personal viewpoint (clearly marked): For these reasons, I actively block DoH in my environments using Response Policy Zones (RPZ) in BIND. This allows me to enforce DNS policy consistently, prevent silent resolver bypassing, and keep DNS traffic observable and controllable—while still supporting encrypted DNS via DoT where it makes sense.
In short: encryption is not the problem. Loss of control, visibility, and centralisation of data at a few hyperscalers is.
What is DNS over HTTPS?
DNS over HTTPS (DoH) encrypts DNS queries within regular HTTPS traffic, making them indistinguishable from normal web browsing. This provides privacy benefits but requires a different architectural approach than DoT. This post covers implementing DoH for your BIND resolver using nginx as a reverse proxy.
Understanding DNS over HTTPS
How DoH Works
DoH encapsulates DNS queries in HTTP/2 requests over TLS:
- Client sends DNS query as HTTP POST or GET to
/dns-query - Query encoded as
application/dns-message(RFC 8484) - Response returned as HTTP response body
- All traffic flows over standard HTTPS (port 443)
Wire Format vs JSON
DoH supports two formats:
Wire Format (RFC 8484) - Standard DNS binary format over HTTP:
- Content-Type:
application/dns-message - More efficient, widely supported
- Used by browsers and system resolvers
JSON Format (RFC 8427) - Human-readable JSON:
- Content-Type:
application/dns-json - Easier to debug and inspect
- Used by some API-based clients
Why DoH?
| Advantages/Disadvantages | Description |
|---|---|
| Privacy | ISPs cannot see DNS queries |
| Censorship resistance | Hard to block (uses port 443) |
| Browser integration | Native support in Firefox, Chrome |
| Enterprise bypass | Can bypass network DNS policies |
Architecture Options
BIND does not natively support DoH (unlike DoT). You need a proxy layer:
Client --HTTPS--> nginx (DoH) --DNS--> BIND
Option 1: nginx with dns-over-https proxy module
Option 2: nginx with dnsdist backend
Option 3: Dedicated DoH proxy (doh-server)
We'll cover all three approaches.
Prerequisites
- Working BIND resolver accepting queries on localhost
- nginx with HTTP/2 support
- Valid TLS certificate
- Port 443 available (or separate IP/hostname)
Option 1: nginx with DNS Proxy Module
Using nginx compiled with the stream module and a simple proxy setup.
Install nginx with Stream Module
# Ubuntu/Debian - nginx comes with stream module
apt install nginx libnginx-mod-stream
# Verify stream module
nginx -V 2>&1 | grep -o with-stream
Basic DoH Configuration with lua-resty-dns
This requires nginx with Lua support (OpenResty or nginx-extras):
# Install OpenResty
apt install -y gnupg
curl -fsSL https://openresty.org/package/pubkey.gpg | apt-key add -
echo "deb http://openresty.org/package/ubuntu $(lsb_release -sc) main" > /etc/apt/sources.list.d/openresty.list
apt update && apt install openresty
# /etc/openresty/nginx.conf
worker_processes auto;
events {
worker_connections 1024;
}
http {
# Upstream BIND resolver
upstream dns_backend {
server 127.0.0.1:53;
}
# DoH server
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 ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers on;
# DoH endpoint
location /dns-query {
# Handle GET requests (base64url encoded query in dns parameter)
if ($request_method = GET) {
# Process via Lua
content_by_lua_block {
local resolver = require "resty.dns.resolver"
local base64 = require "ngx.base64"
local dns_query = ngx.var.arg_dns
if not dns_query then
ngx.status = 400
ngx.say("Missing dns parameter")
return
end
-- Decode base64url
local query_data = base64.decode_base64url(dns_query)
-- Forward to BIND
local sock = ngx.socket.udp()
sock:setpeername("127.0.0.1", 53)
sock:send(query_data)
local response = sock:receive()
sock:close()
ngx.header["Content-Type"] = "application/dns-message"
ngx.print(response)
}
}
# Handle POST requests (binary DNS query in body)
if ($request_method = POST) {
content_by_lua_block {
ngx.req.read_body()
local query_data = ngx.req.get_body_data()
if not query_data then
ngx.status = 400
ngx.say("Empty request body")
return
end
-- Forward to BIND
local sock = ngx.socket.udp()
sock:setpeername("127.0.0.1", 53)
sock:send(query_data)
local response = sock:receive()
sock:close()
ngx.header["Content-Type"] = "application/dns-message"
ngx.print(response)
}
}
}
# Health check endpoint
location /health {
return 200 "OK";
}
}
}
Option 2: Using dnsdist as DoH Frontend
dnsdist (from PowerDNS) provides excellent DoH support:
Install dnsdist
# Ubuntu/Debian
apt install dnsdist
Configure dnsdist for DoH
-- /etc/dnsdist/dnsdist.conf
-- Backend BIND server
newServer({address="127.0.0.1:53", name="bind-local"})
-- DoH frontend
addDOHLocal(
"0.0.0.0:443",
"/etc/letsencrypt/live/dns.example.com/fullchain.pem",
"/etc/letsencrypt/live/dns.example.com/privkey.pem",
{ "/dns-query" },
{
minTLSVersion = "tls1.2",
ciphers = "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384",
customResponseHeaders = {
["Cache-Control"] = "max-age=300"
}
}
)
-- Also serve DoT on 853
addTLSLocal(
"0.0.0.0:853",
"/etc/letsencrypt/live/dns.example.com/fullchain.pem",
"/etc/letsencrypt/live/dns.example.com/privkey.pem",
{
minTLSVersion = "tls1.2"
}
)
-- Access control
addACL("192.168.0.0/16")
addACL("10.0.0.0/8")
addACL("127.0.0.0/8")
-- Logging
addAction(AllRule(), LogAction("/var/log/dnsdist/queries.log", false, true))
-- Web interface for monitoring
webserver("127.0.0.1:8083", "admin", "secret-password")
setWebserverConfig({
acl = "127.0.0.1"
})
Start dnsdist
systemctl enable dnsdist
systemctl start dnsdist
# Test DoH
curl -H 'accept: application/dns-message' \
"https://dns.example.com/dns-query?dns=AAABAAABAAAAAAAAB2V4YW1wbGUDY29tAAABAAE=" \
-o - | xxd
Option 3: Dedicated DoH Proxy (doh-server)
The doh-server from Facebook/m13253 is a lightweight, purpose-built DoH proxy.
Install doh-server
# Download latest release
wget https://github.com/m13253/dns-over-https/releases/download/v2.3.4/doh-server-linux-amd64.tar.gz
tar xzf doh-server-linux-amd64.tar.gz
mv doh-server /usr/local/bin/
# Create config directory
mkdir -p /etc/dns-over-https
Configure doh-server
# /etc/dns-over-https/doh-server.conf
listen = [
"127.0.0.1:8053",
"[::1]:8053",
]
path = "/dns-query"
upstream = [
"udp:127.0.0.1:53",
]
timeout = 10
tries = 3
verbose = false
log_guessed_client_ip = false
# ECS (EDNS Client Subnet) handling
ecs_allow_non_global_ip = false
ecs_use_precise_ip = false
nginx as TLS Terminator
# /etc/nginx/sites-available/doh
upstream doh_backend {
server 127.0.0.1:8053;
keepalive 30;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name dns.example.com;
# TLS configuration
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 ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
# DoH endpoint
location /dns-query {
proxy_pass http://doh_backend/dns-query;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Important for DoH
proxy_http_version 1.1;
proxy_set_header Connection "";
# Timeouts
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
# Access control
allow 192.168.0.0/16;
allow 10.0.0.0/8;
deny all;
}
# Health check
location /health {
access_log off;
return 200 "healthy\n";
}
# Block everything else
location / {
return 404;
}
}
Systemd Service for doh-server
# /etc/systemd/system/doh-server.service
[Unit]
Description=DNS over HTTPS Server
After=network.target
Requires=named.service
[Service]
Type=simple
ExecStart=/usr/local/bin/doh-server -conf /etc/dns-over-https/doh-server.conf
Restart=always
RestartSec=5
User=nobody
Group=nogroup
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable doh-server
systemctl start doh-server
Testing DoH
Using curl
# POST method (recommended)
curl -s -H 'content-type: application/dns-message' \
-H 'accept: application/dns-message' \
--data-binary @- \
'https://dns.example.com/dns-query' < <(echo -n 'AAABAAABAAAAAAAAB2V4YW1wbGUDY29tAAABAAE=' | base64 -d) | xxd
# GET method with base64url encoded query
DNS_QUERY=$(echo -n 'AAABAAABAAAAAAAAB2V4YW1wbGUDY29tAAABAAE=' | base64 -d | base64 | tr '+/' '-_' | tr -d '=')
curl -s -H 'accept: application/dns-message' \
"https://dns.example.com/dns-query?dns=$DNS_QUERY" | xxd
Using kdig
# kdig from knot-dnsutils supports DoH
kdig +https @dns.example.com example.com A
# With custom path
kdig +https @dns.example.com /dns-query example.com A
Using dog
# dog is a modern DNS client
dog --https @https://dns.example.com/dns-query example.com A
Client Configuration
Firefox
- Open
about:config - Set
network.trr.modeto2(prefer DoH) or3(DoH only) - Set
network.trr.uritohttps://dns.example.com/dns-query - Optionally set
network.trr.bootstrapAddressto your server's IP
Chrome/Edge
- Settings -> Privacy and Security -> Security
- Enable "Use secure DNS"
- Select "Custom" and enter
https://dns.example.com/dns-query
systemd-resolved (Linux)
# /etc/systemd/resolved.conf
[Resolve]
DNS=192.168.1.1#dns.example.com
DNSOverTLS=yes
# Note: systemd-resolved uses DoT, not DoH directly
For DoH on Linux, use a local DoH client like dnscrypt-proxy:
# /etc/dnscrypt-proxy/dnscrypt-proxy.toml
listen_addresses = ['127.0.0.1:53']
[static]
[static.'myresolver']
stamp = 'sdns://AgcAAAAAAAAADjE5Mi4xNjguMS4xOjQ0MyASZG5zLmV4YW1wbGUuY29tCi9kbnMtcXVlcnk'
JSON API Support
Some clients prefer JSON format. Here's how to add support with nginx:
location /dns-query {
# Check Accept header for JSON
set $dns_format "wire";
if ($http_accept ~* "application/dns-json") {
set $dns_format "json";
}
# For JSON, use Google's format
if ($dns_format = "json") {
# Handle JSON queries
content_by_lua_block {
local resolver = require "resty.dns.resolver"
local cjson = require "cjson"
local name = ngx.var.arg_name
local qtype = ngx.var.arg_type or "A"
if not name then
ngx.status = 400
ngx.say(cjson.encode({Status = 2, Comment = "Missing name parameter"}))
return
end
local r, err = resolver:new{
nameservers = {{"127.0.0.1", 53}},
timeout = 2000,
}
if not r then
ngx.status = 500
ngx.say(cjson.encode({Status = 2, Comment = err}))
return
end
local answers, err = r:query(name, {qtype = resolver[qtype]})
if not answers then
ngx.status = 500
ngx.say(cjson.encode({Status = 2, Comment = err}))
return
end
-- Format response
local response = {
Status = 0,
TC = false,
RD = true,
RA = true,
AD = false,
CD = false,
Question = {{name = name, type = qtype}},
Answer = {}
}
for i, ans in ipairs(answers) do
table.insert(response.Answer, {
name = ans.name,
type = ans.type,
TTL = ans.ttl,
data = ans.address or ans.cname or ans.txt
})
end
ngx.header["Content-Type"] = "application/dns-json"
ngx.say(cjson.encode(response))
}
}
# Wire format handling...
}
Performance Optimization
HTTP/2 Configuration
http {
# Enable HTTP/2 push
http2_push_preload on;
# Increase buffer sizes for DNS responses
http2_max_field_size 16k;
http2_max_header_size 32k;
# Connection keepalive
keepalive_timeout 300s;
keepalive_requests 10000;
}
Caching DoH Responses
proxy_cache_path /var/cache/nginx/doh
levels=1:2
keys_zone=doh_cache:10m
max_size=100m
inactive=5m;
server {
location /dns-query {
proxy_cache doh_cache;
proxy_cache_valid 200 300s;
proxy_cache_key $request_body$arg_dns;
proxy_cache_methods GET POST;
# Add cache status header
add_header X-Cache-Status $upstream_cache_status;
}
}
Security Considerations
Rate Limiting
# Define rate limit zone
limit_req_zone $binary_remote_addr zone=doh_limit:10m rate=50r/s;
server {
location /dns-query {
limit_req zone=doh_limit burst=100 nodelay;
limit_req_status 429;
}
}
Access Control
server {
location /dns-query {
# Allow private networks
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
# Block everyone else
deny all;
# Or use GeoIP
# if ($geoip_country_code != "US") {
# return 403;
# }
}
}
Request Validation
server {
location /dns-query {
# Only allow proper content types
if ($http_content_type !~ "^application/dns-message") {
return 415;
}
# Limit request body size
client_max_body_size 512;
}
}
Complete Production Configuration
# /etc/nginx/nginx.conf
user www-data;
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
http {
# Basic settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
types_hash_max_size 2048;
server_tokens off;
# Rate limiting
limit_req_zone $binary_remote_addr zone=doh_limit:10m rate=100r/s;
limit_conn_zone $binary_remote_addr zone=doh_conn:10m;
# Logging
log_format doh '$remote_addr - [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_user_agent" rt=$request_time';
access_log /var/log/nginx/doh-access.log doh;
error_log /var/log/nginx/doh-error.log warn;
# DoH upstream
upstream doh_backend {
server 127.0.0.1:8053;
keepalive 100;
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name dns.example.com;
return 301 https://$host$request_uri;
}
# DoH server
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name dns.example.com;
# TLS
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 ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 127.0.0.1 valid=300s;
# Security headers
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
# DoH endpoint
location /dns-query {
# Rate limiting
limit_req zone=doh_limit burst=200 nodelay;
limit_conn doh_conn 50;
# Access control
allow 192.168.0.0/16;
allow 10.0.0.0/8;
deny all;
# Proxy to doh-server
proxy_pass http://doh_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Timeouts
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
location /health {
access_log off;
return 200 "OK\n";
}
location / {
return 404;
}
}
}
Troubleshooting
Check DoH Service
# Test backend directly
curl -v http://127.0.0.1:8053/dns-query
# Test through nginx
curl -v https://dns.example.com/dns-query
# Debug DNS query
echo -ne '\x00\x00\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x07example\x03com\x00\x00\x01\x00\x01' | \
curl -s -X POST -H 'content-type: application/dns-message' \
--data-binary @- https://dns.example.com/dns-query | xxd
Common Issues
502 Bad Gateway:
# Check if doh-server is running
systemctl status doh-server
ss -tlnp | grep 8053
SSL errors:
# Verify certificate chain
openssl s_client -connect dns.example.com:443 -servername dns.example.com
DNS resolution failures:
# Check BIND is responding
dig @127.0.0.1 example.com A
Conclusion
DNS over HTTPS provides strong privacy protection and censorship resistance. While BIND doesn't support DoH natively, combining it with nginx and doh-server (or dnsdist) creates a robust DoH service.
Key takeaways:
- Use dnsdist for the simplest setup with both DoH and DoT
- Use doh-server + nginx for maximum flexibility
- Implement rate limiting and access controls
- Enable HTTP/2 for best performance
- Test with multiple clients (browsers, curl, kdig)
The final post in this BIND series will cover monitoring your DNS infrastructure with Prometheus and Grafana, giving you visibility into query patterns, performance, and security events.