With major browsers enabling DNS over HTTPS (DoH) by default, organizations face a choice: let DNS queries flow to third-party resolvers like Google or Cloudflare, or take control by running their own DoH server. This guide shows you how to deploy an NGINX DNS over HTTPS endpoint using the ngx_http_doh_module, giving you complete control over DNS privacy and compliance.
What is DNS over HTTPS?
DNS over HTTPS encrypts DNS queries by sending them inside HTTPS connections on port 443. Standardized in RFC 8484 in October 2018, DoH prevents network observers from seeing which domains you resolve. Traditional DNS uses unencrypted UDP on port 53, making queries visible to anyone monitoring the network path.
The protocol wraps DNS wire-format messages in HTTPS payloads with the MIME type application/dns-message. This design makes DoH traffic indistinguishable from regular web browsing, providing both encryption and privacy through obscurity.
Why Run Your Own NGINX DNS over HTTPS Server?
Privacy and Compliance
When your users rely on public DoH resolvers, their DNS queries travel to servers outside your control. This creates compliance challenges under regulations like GDPR, which requires organizations to account for all personal data processing, including metadata like DNS queries.
The European Union recognized this concern with the DNS4EU initiative, launched in 2025 to provide EU-operated DNS resolution. However, running your own NGINX DNS over HTTPS server gives you complete control over DNS metadata retention and processing.
Browser Compatibility
Firefox enabled DoH by default for US users in February 2020. Chrome followed in May 2020. Today, all major browsers support DoH, and many enable it automatically. By running your own server, you can:
- Configure company browsers to use your resolver
- Maintain visibility into DNS queries for security monitoring
- Ensure DNS resolution stays within your network perimeter
- Apply custom filtering or blocking policies
Performance Benefits
A local DoH server reduces DNS latency by keeping queries on your network. Instead of round-trips to distant public resolvers, queries go to your NGINX server, which forwards them to your preferred upstream resolver over the local network.
How the NGINX DoH Module Works
The ngx_http_doh_module acts as a protocol translator. It receives DoH requests over HTTPS, extracts the DNS query, forwards it to a traditional DNS resolver over UDP or TCP, and returns the response wrapped in HTTPS.
The module handles both GET and POST methods as specified in RFC 8484:
- GET requests carry the DNS query in a base64url-encoded
dnsparameter - POST requests send the raw DNS message in the request body
When the upstream resolver responds with a truncated answer (TC flag set), the module automatically retries using TCP to retrieve the complete response. Additionally, the module parses DNS responses to set appropriate Cache-Control headers based on the minimum TTL in the answer section.
Installation
The module is available as a pre-built package from the GetPageSpeed repository for RHEL-based distributions including Rocky Linux, AlmaLinux, and CentOS Stream.
Enable the Repository
sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
Install the Module
sudo dnf install nginx-module-doh
Load the Module
Add the following line to the top of /etc/nginx/nginx.conf, before the events block:
load_module modules/ngx_http_doh_module.so;
Configuration Directives
The module provides four directives, all valid only within location blocks.
doh
Enables DoH handling for the location. Takes no arguments.
location /dns-query {
doh;
}
This directive registers the DoH content handler for the location. Without additional configuration, it uses the default upstream resolver at 127.0.0.1:53.
doh_address
Sets the IP address of the upstream DNS resolver. Accepts IPv4 or IPv6 addresses.
Default: 127.0.0.1
doh_address 192.168.1.1;
For IPv6 resolvers:
doh_address 2606:4700:4700::1111;
doh_port
Sets the port number for the upstream DNS resolver.
Default: 53
doh_port 5353;
doh_timeout
Sets the timeout for upstream DNS queries in milliseconds.
Default: 5000 (5 seconds)
doh_timeout 3000;
If the upstream resolver does not respond within this time, NGINX returns HTTP 204 No Content.
Basic NGINX DNS over HTTPS Configuration
The simplest DoH setup forwards queries to a public DNS resolver:
load_module modules/ngx_http_doh_module.so;
events {
worker_connections 1024;
}
http {
server {
listen 443 ssl;
http2 on;
server_name doh.example.com;
ssl_certificate /etc/ssl/certs/doh.example.com.crt;
ssl_certificate_key /etc/ssl/private/doh.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
location /dns-query {
doh;
doh_address 1.1.1.1;
}
}
}
This configuration:
- Listens on port 443 with TLS encryption
- Enables HTTP/2 for better performance
- Forwards DNS queries to Cloudflare’s public resolver
- Uses the standard
/dns-querypath expected by browsers
This approach encrypts the client-to-NGINX connection, preventing local network snooping. However, queries still reach an external provider.
Production NGINX DNS over HTTPS Configuration
A production deployment requires additional security hardening and operational features.
Complete Example
load_module modules/ngx_http_doh_module.so;
events {
worker_connections 1024;
}
http {
# Rate limiting zone for DoH requests
limit_req_zone $binary_remote_addr zone=doh_limit:10m rate=10r/s;
# Custom log format for DoH monitoring
log_format doh '$remote_addr - [$time_local] "$request" '
'$status $body_bytes_sent rt=$request_time';
server {
listen 443 ssl;
http2 on;
server_name doh.example.com;
ssl_certificate /etc/ssl/certs/doh.example.com.crt;
ssl_certificate_key /etc/ssl/private/doh.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
location /dns-query {
# Rate limit to prevent abuse
limit_req zone=doh_limit burst=20 nodelay;
# DoH configuration
doh;
doh_address 127.0.0.53;
doh_port 53;
doh_timeout 5000;
# Logging
access_log /var/log/nginx/doh.log doh;
}
# Reject all other requests
location / {
return 404;
}
}
}
Choosing an Upstream Resolver
The DoH module forwards queries to a traditional DNS resolver. Your choice of upstream resolver determines your privacy level:
Option 1: Public Resolver
Forward to a public DNS service like Cloudflare (1.1.1.1) or Google (8.8.8.8):
location /dns-query {
doh;
doh_address 1.1.1.1;
}
Pros: No additional software to manage, reliable, fast
Cons: DNS queries leave your network, third party sees your queries
This encrypts the client-to-NGINX leg but still sends queries externally.
Option 2: systemd-resolved (Simple Local Caching)
RHEL 10 and derivatives do not include a local DNS resolver by default. The system uses NetworkManager with external DNS servers from DHCP. For local caching without complex configuration, install systemd-resolved:
sudo dnf install systemd-resolved
sudo systemctl enable --now systemd-resolved
Verify it is running:
resolvectl status
systemd-resolved listens on 127.0.0.53. Configure NGINX to use it:
location /dns-query {
doh;
doh_address 127.0.0.53;
}
Pros: Simple setup, local caching, integrates with NetworkManager
Cons: Still forwards to upstream resolvers configured via DHCP or /etc/systemd/resolved.conf
To configure systemd-resolved to use specific upstream servers, edit /etc/systemd/resolved.conf:
[Resolve]
DNS=1.1.1.1 9.9.9.9
FallbackDNS=8.8.8.8
Then restart the service:
sudo systemctl restart systemd-resolved
Option 3: Unbound (Full Recursive Resolution)
For complete privacy with no external DNS dependencies, run Unbound as a recursive resolver that queries authoritative DNS servers directly:
sudo dnf install unbound
Configure /etc/unbound/unbound.conf:
server:
interface: 127.0.0.1
port: 53
access-control: 127.0.0.0/8 allow
do-ip6: no
prefetch: yes
cache-min-ttl: 300
Start Unbound:
sudo systemctl enable --now unbound
Configure NGINX to use it:
location /dns-query {
doh;
doh_address 127.0.0.1;
}
Pros: Complete privacy, no third-party dependency, full control over DNS
Cons: More complex setup, requires DNSSEC root trust anchor management
With Unbound, DNS queries stay entirely within your infrastructure. Browsers connect to your NGINX DoH server, which queries your local Unbound, which performs recursive resolution against authoritative DNS servers.
Testing Your NGINX DNS over HTTPS Server
Verify with curl
Test a DNS query using curl with the proper headers:
curl -v -H "Accept: application/dns-message" \
-H "Content-Type: application/dns-message" \
"https://doh.example.com/dns-query?dns=AAABAAABAAAAAAAAA3d3dwdleGFtcGxlA2NvbQAAAQAB"
The base64url-encoded string represents a DNS query for www.example.com. A successful response returns HTTP 200 with Content-Type: application/dns-message.
Test with Firefox
- Open
about:configin Firefox - Set
network.trr.modeto3(DoH only) - Set
network.trr.urito `https://doh.example.com/dns-query` - Browse to any website to verify resolution works
Test with Chrome
- Open
chrome://settings/security - Enable “Use secure DNS”
- Select “Custom” and enter `https://doh.example.com/dns-query`
Performance Considerations
Connection Reuse
HTTP/2 multiplexes multiple requests over a single connection, reducing handshake overhead. The module efficiently handles concurrent queries through NGINX’s event-driven architecture.
Upstream Protocol Selection
The module uses UDP by default for upstream queries, which minimizes latency. It automatically falls back to TCP when responses are truncated, ensuring complete answers for large DNS responses like those containing many IP addresses or DNSSEC signatures.
Memory Usage
Each active DoH request allocates buffer space for the DNS query and response. The module uses NGINX’s memory pool allocator, which releases memory when the request completes. Under normal load, memory usage scales linearly with concurrent requests.
Timeout Tuning
The default 5-second timeout suits most deployments. Reduce it if your upstream resolver is local and responsive:
doh_timeout 2000;
Increase it only if you experience intermittent timeouts with a slow upstream resolver, but investigate the root cause first.
Security Best Practices
TLS Configuration
Always use TLS 1.2 or 1.3. NGINX DNS over HTTPS without encryption defeats its purpose:
ssl_protocols TLSv1.2 TLSv1.3;
Obtain certificates from a trusted CA. Self-signed certificates will cause browser warnings or outright rejection.
Rate Limiting
DNS amplification attacks abuse open resolvers. Rate limiting prevents your server from participating in such attacks:
limit_req_zone $binary_remote_addr zone=doh_limit:10m rate=10r/s;
location /dns-query {
limit_req zone=doh_limit burst=20 nodelay;
doh;
}
This configuration allows 10 requests per second per IP address, with bursts up to 20 requests handled immediately.
Access Control
For internal deployments, restrict access by IP address using the NGINX allow and deny directives:
location /dns-query {
allow 10.0.0.0/8;
allow 192.168.0.0/16;
deny all;
doh;
}
Logging and Monitoring
Log DoH requests for security monitoring. The custom log format shown earlier captures essential fields without logging the actual DNS queries, balancing visibility with privacy:
log_format doh '$remote_addr - [$time_local] "$request" '
'$status $body_bytes_sent rt=$request_time';
For deeper inspection, analyze the DNS wire format in the request body, but be aware this may have privacy implications. You can also integrate with the NGINX VTS module for real-time traffic statistics.
Troubleshooting
HTTP 400 Bad Request
The client sent an invalid DoH request. Common causes:
- Missing
Content-Type: application/dns-messageheader - Missing
Accept: application/dns-messageheader - Malformed base64url encoding in the
dnsparameter - Invalid DNS wire format in the request body
HTTP 204 No Content
The upstream DNS resolver did not respond within the timeout period. Check:
- Upstream resolver availability with
dig @127.0.0.53 example.com - Firewall rules allowing UDP/TCP port 53 to the upstream resolver
- Network connectivity between NGINX and the resolver
HTTP 405 Method Not Allowed
The client used an HTTP method other than GET or POST. Only these two methods are valid for DoH.
HTTP 500 Internal Server Error
An internal error occurred, typically during socket creation or memory allocation. Check the NGINX error log for details:
tail -f /var/log/nginx/error.log
Combining with Other NGINX Modules
The DoH module works alongside other NGINX modules. For example, you can use the GeoIP2 module to restrict access by country, or the ModSecurity WAF for additional request filtering.
You can also use the NGINX map directive to create conditional configurations based on client characteristics.
Conclusion
Running your own NGINX DNS over HTTPS server gives you control over DNS privacy and compliance. The ngx_http_doh_module provides a lightweight, efficient implementation of RFC 8484 that integrates seamlessly with existing NGINX deployments.
Key takeaways:
- DoH encrypts DNS queries, preventing network observers from seeing resolved domains
- Browser adoption of DoH makes self-hosted servers increasingly relevant
- The module translates between DoH and traditional DNS protocols
- Production deployments require TLS, rate limiting, and access controls
- Use systemd-resolved for simple local caching, or Unbound for full recursive resolution
For more information, see the module source code on GitHub.

