Skip to main content

NGINX

NGINX DNS over HTTPS: Run Your Own DoH Server

by ,


We have by far the largest RPM repository with NGINX module packages and VMODs for Varnish. If you want to install NGINX, Varnish, and lots of useful performance/security software with smooth yum upgrades for production use, this is the repository for you.
Active subscription is required.

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 dns parameter
  • 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-query path 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

  1. Open about:config in Firefox
  2. Set network.trr.mode to 3 (DoH only)
  3. Set network.trr.uri to `https://doh.example.com/dns-query`
  4. Browse to any website to verify resolution works

Test with Chrome

  1. Open chrome://settings/security
  2. Enable “Use secure DNS”
  3. 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-message header
  • Missing Accept: application/dns-message header
  • Malformed base64url encoding in the dns parameter
  • 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.

D

Danila Vershinin

Founder & Lead Engineer

NGINX configuration and optimizationLinux system administrationWeb performance engineering

10+ years NGINX experience • Maintainer of GetPageSpeed RPM repository • Contributor to open-source NGINX modules

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes:

<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

This site uses Akismet to reduce spam. Learn how your comment data is processed.