Implementing DNS over HTTPS (DoH) with BIND and nginx

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:

  1. Client sends DNS query as HTTP POST or GET to /dns-query
  2. Query encoded as application/dns-message (RFC 8484)
  3. Response returned as HTTP response body
  4. 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

  1. Open about:config
  2. Set network.trr.mode to 2 (prefer DoH) or 3 (DoH only)
  3. Set network.trr.uri to https://dns.example.com/dns-query
  4. Optionally set network.trr.bootstrapAddress to your server's IP

Chrome/Edge

  1. Settings -> Privacy and Security -> Security
  2. Enable "Use secure DNS"
  3. 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.

Read more

HAProxy Monitoring with Prometheus: Complete Observability Guide

HAProxy Monitoring with Prometheus: Complete Observability Guide

Monitoring HAProxy is essential for maintaining reliable load balancing infrastructure. Prometheus provides powerful metrics collection, alerting capabilities, and seamless Grafana integration for visualizing HAProxy performance and health. Why Prometheus for HAProxy? Prometheus offers: * Pull-based metrics - Prometheus scrapes HAProxy metrics endpoints * Time-series database - Store historical data for trend analysis

By Patrick de Ruiter