Fast, mature, secure DoH and ODoH server proxy written in Rust. Previously known as doh-proxy and rust-doh.
Rust
1,074
391 commits
updated Oct 11, 2026

A fast and secure DoH (DNS-over-HTTPS) and ODoH (Oblivious DoH) server.
doh-proxy is written in Rust, and has been battle-tested in production since February 2018. It doesn't do DNS resolution on its own, but can sit in front of any DNS resolver in order to augment it with DoH support.
Precompiled tarballs and Debian packages for Linux/x86_64 can be downloaded here.
This requires the rust compiler to be installed.
cargo install doh-proxy
cargo install doh-proxy --no-default-features
# Simple setup with a local DNS resolver
doh-proxy -H 'doh.example.com' -u 127.0.0.1:53
# With a specific public IP address
doh-proxy -H 'doh.example.com' -u 127.0.0.1:53 -g 203.0.113.1
# With built-in TLS support
doh-proxy -H 'doh.example.com' -u 127.0.0.1:53 -i /path/to/cert.pem -I /path/to/key.pem
USAGE:
doh-proxy [FLAGS] [OPTIONS]
FLAGS:
-O, --allow-odoh-post Allow POST queries over ODoH even if they have been disabed for DoH
-K, --disable-keepalive Disable keepalive
-P, --disable-post Disable POST queries
-h, --help Prints help information
-V, --version Prints version information
OPTIONS:
-E, --err-ttl <err_ttl> TTL for errors, in seconds [default: 2]
-H, --hostname <hostname> Host name (not IP address) DoH clients will use to connect
-l, --listen-address <listen_address> Address to listen to [default: 127.0.0.1:3000]
-b, --local-bind-address <local_bind_address> Address to connect from
-c, --max-clients <max_clients> Maximum number of simultaneous client connections, TLS handshakes included [default: 512]
-C, --max-concurrent <max_concurrent> Maximum number of concurrent requests per client [default: 16]
-X, --max-ttl <max_ttl> Maximum TTL, in seconds [default: 604800]
-T, --min-ttl <min_ttl> Minimum TTL, in seconds [default: 10]
-p, --path <path> URI path [default: /dns-query]
-g, --public-address <public_address> External IP address(es) DoH clients will connect to (can be specified multiple times)
-j, --public-port <public_port> External port DoH clients will connect to, if not 443
-u, --server-address <server_address> Address to connect to [default: 9.9.9.9:53]
-t, --timeout <timeout> Time budget of a request, in seconds (1 to 3600). Idle connections are closed after this delay, sooner when the server is busy [default: 10]
-I, --tls-cert-key-path <tls_cert_key_path>
Path to the PEM-encoded secret keys (only required for built-in TLS)
-i, --tls-cert-path <tls_cert_path>
Path to the PEM/PKCS#8-encoded certificates (only required for built-in TLS)
--enable-ecs Enable EDNS Client Subnet
--ecs-prefix-v4 <ecs_prefix_v4> IPv4 prefix length for EDNS Client Subnet [default: 24]
--ecs-prefix-v6 <ecs_prefix_v6> IPv6 prefix length for EDNS Client Subnet [default: 56]
Basic setup with custom DNS resolver:
doh-proxy -H 'doh.example.com' -u 8.8.8.8:53 -g 203.0.113.1
Multiple IP addresses for load balancing:
doh-proxy -H 'doh.example.com' -u 127.0.0.1:53 -g 203.0.113.1 -g 203.0.113.2 -g 2001:db8::1
This generates separate DNS stamps for each IP address, allowing clients to connect via any of them.
Production setup with TLS and custom limits:
doh-proxy -H 'doh.example.com' \
-u 127.0.0.1:53 \
-l 0.0.0.0:443 \
-i /etc/letsencrypt/live/doh.example.com/fullchain.pem \
-I /etc/letsencrypt/live/doh.example.com/privkey.pem \
-c 1000 \
-C 32
Behind a reverse proxy (nginx/Caddy):
doh-proxy -H 'doh.example.com' -u 127.0.0.1:53 -l 127.0.0.1:3000
The recommended deployment is behind a TLS termination proxy such as nginx, Caddy, HAProxy, or a CDN. This allows:
Example with nginx:
server {
listen 443 ssl http2;
server_name doh.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location /dns-query {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Example with Caddy:
doh.example.com {
reverse_proxy /dns-query localhost:3000
}
For simpler deployments or when running on separate infrastructure:
doh-proxy -H 'doh.example.com' \
-u 127.0.0.1:53 \
-l 0.0.0.0:443 \
-i /path/to/fullchain.pem \
-I /path/to/privkey.pem
Certificate Requirements:
If using ECDSA certificates that start with -----BEGIN EC PRIVATE KEY-----, convert to PKCS#8:
openssl pkcs8 -topk8 -nocrypt -in example.key -out example.pkcs8.pem
Using Let's Encrypt with acme.sh:
# Install acme.sh
curl https://get.acme.sh | sh
# Get certificates
acme.sh --issue -d doh.example.com --webroot /var/www/html
# Run doh-proxy with Let's Encrypt certificates
doh-proxy -H 'doh.example.com' \
-u 127.0.0.1:53 \
-i ~/.acme.sh/doh.example.com/fullchain.cer \
-I ~/.acme.sh/doh.example.com/doh.example.com.key
Note: Once HTTPS is enabled, HTTP connections will not be accepted. A sample self-signed certificate
localhost.pemis available for testing.
Encrypted DNS Server can handle both DNSCrypt and DoH on the same port:
# In encrypted-dns-server.toml
[tls]
upstream_addr = "127.0.0.1:3000"
This provides:
location /dns-query {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
The example above opens a fresh backend connection for each request.
To enable connection reuse, use an upstream block with keepalive:
upstream doh_backend {
server 127.0.0.1:3000;
keepalive 16;
}
location /dns-query {
proxy_pass http://doh_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_next_upstream error timeout non_idempotent;
}
doh-proxy closes connections that stay idle, like most servers.
If nginx reuses a connection just as it closes, that request attempt can fail.
nginx retries GET requests by default, and proxy_next_upstream ... non_idempotent allows it to retry POST queries as well.
DoH and ODoH queries can safely be repeated.
Retries are only possible before nginx starts sending the response to the client.
Setting keepalive_timeout 5s; in the upstream block can reduce these retries at normal load with the default --timeout 10, but is optional.
Under connection pressure, doh-proxy may close idle connections sooner, so a shorter nginx timeout does not replace retries.
backend doh_backend
mode http
server doh1 127.0.0.1:3000 check
The server supports Google's DNS-over-HTTPS JSON API format, making it compatible with applications that use this format.
Send GET requests to /dns-query with Accept: application/dns-json header:
# Query A records
curl -H "Accept: application/dns-json" \
"http://localhost:3000/dns-query?name=example.com&type=1"
# Query with multiple parameters
curl -H "Accept: application/dns-json" \
"http://localhost:3000/dns-query?name=example.com&type=28&cd=1&do=1"
name - Domain name to query (required)type - DNS record type (default: 1 for A records)cd - Disable DNSSEC validation (0 or 1)do - Request DNSSEC data (0 or 1)edns_client_subnet - Client subnet for EDNS{
"Status": 0,
"TC": false,
"RD": true,
"RA": true,
"AD": false,
"CD": false,
"Question": [{
"name": "example.com",
"type": 1
}],
"Answer": [{
"name": "example.com",
"type": 1,
"TTL": 300,
"data": "93.184.216.34"
}]
}
EDNS Client Subnet (ECS) is a DNS extension that allows the DoH proxy to forward client IP information to upstream DNS resolvers. This enables geo-optimized DNS responses by allowing authoritative nameservers to return results based on the client's actual location rather than the proxy's location.
Enable ECS support with the following command-line options:
--enable-ecs - Enable EDNS Client Subnet functionality--ecs-prefix-v4 <length> - IPv4 prefix length (default: 24)--ecs-prefix-v6 <length> - IPv6 prefix length (default: 56)Client IP Extraction: The proxy extracts the client's IP address from:
X-Forwarded-For header (takes the first IP if multiple are present)X-Real-IP headerIP Truncation: For privacy, client IPs are truncated to the configured prefix length:
DNS Query Enhancement: The truncated client subnet is added to outgoing DNS queries using the EDNS0 Client Subnet option (RFC 7871).
Geo-Optimized Responses: Upstream resolvers and authoritative nameservers can use this information to return geographically appropriate results.
Basic ECS setup:
doh-proxy -H 'doh.example.com' -u 8.8.8.8:53 --enable-ecs
Custom prefix lengths for more privacy:
doh-proxy -H 'doh.example.com' -u 8.8.8.8:53 \
--enable-ecs \
--ecs-prefix-v4 16 \
--ecs-prefix-v6 48
Behind nginx with ECS enabled:
server {
listen 443 ssl http2;
server_name doh.example.com;
location /dns-query {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Testing ECS functionality:
# With X-Forwarded-For header
curl -H "X-Forwarded-For: 1.2.3.4" \
-H "Accept: application/dns-json" \
"https://doh.example.com/dns-query?name=example.com&type=1"
# The DNS query sent to upstream will include ECS: 1.2.0.0/24
For maximum privacy, avoid enabling ECS or use larger prefix values (smaller subnets) like /8 for IPv4 or /32 for IPv6.
Oblivious DoH is similar to Anonymized DNSCrypt, but for DoH. It requires relays, but also upstream DoH servers that support the protocol.
This proxy supports ODoH termination (not relaying) out of the box.
However, ephemeral keys are currently only stored in memory. In a load-balanced configuration, sticky sessions must be used.
Currently available ODoH relays only use POST queries.
So, POST queries have been disabled for regular DoH queries, accepting them is required to be compatible with ODoH relays.
This can be achieved with the --allow-odoh-post command-line switch.
--timeout (10 seconds by default, between 1 and 3600) is the time budget of a single request.
It starts once the request headers have been received, and covers reading the request body, waiting for the upstream resolver, and retrying over TCP when a UDP response was truncated.
A body that doesn't arrive in time gets a 408 response, and a resolver that doesn't answer in time gets a 502.
The same value controls how long connections are kept, so there is nothing else to tune:
--max-clients, idle connections are closed sooner: after half the timeout from 70% of the limit, and after a tenth of it (never less than a second) from 90%.
The delays go back up once the number of connections falls below 80%, and then below 60% of the limit.
Requests in progress are never cut short because of this.Connection: close.
HTTP/2 connections send a GOAWAY frame and complete the streams they had already accepted.
Clients simply open a new connection.With the default timeout, this gives:
+------------------------------------------------+------------+
| Event | Default |
+------------------------------------------------+------------+
| Request budget | 10 s |
| Idle connection closed after | 10 s |
| from 70% / 90% of the connection limit | 5 s / 1 s |
| TLS handshake limit | 10 s |
| from 70% / 90% of the connection limit | 5 s / 2 s |
| Connection retired at the age of | 60 s |
| Retiring connection closed at the latest after | 20 s |
+------------------------------------------------+------------+
--max-clients counts connections from the moment they are accepted, TLS handshakes included.
Connections above the limit are closed right away, before any TLS work.
A proxy that reuses connections to doh-proxy should retry requests that fail because an idle connection was closed at the same moment.
Truncated UDP responses are retried over TCP. The number of simultaneous TCP connections to the upstream resolver is limited separately, to one for every 8 allowed clients (and at least one). A query that needs one waits for a free connection, within its own time budget.
These limits bound how long the server holds on to its resources, but they are a safety net, not a guarantee. Under heavy overload, or with an upstream resolver that stops answering, some queries will still fail.
Use the online DNS stamp calculator to compute the stamp for your server.
Add it to the [static] section of dnscrypt-proxy and check that everything works as expected.
Then, start dnscrypt-proxy with the -show-certs command-line flag to print the hashes for your certificate chain.
Here is an example output:
[NOTICE] Advertised cert: [CN=dohtrial.att.net,O=AT&T Services\, Inc.,L=Dallas,ST=Texas,C=US] [f679e8451940f06141854dc94e1eb79fa5e04463c15b88f3b392da793c16c353]
[NOTICE] Advertised cert: [CN=DigiCert Global CA G2,O=DigiCert Inc,C=US] [f61e576877da9650294cccb5f96c75fcb71bda1bbc4646367c4ebeda89d7318f]
The first printed certificate is the certificate of the server itself. The next line is the one that signed that certificate. As you keep going down, you are getting closer to the certificate authority.
Unless you are using intermediate certificates, your safest option is probably to include the last printed hash certificate in your DNS stamp.
Go back to the online DNS stamp calculator, and copy&paste the hash (in this example: f61e576877da9650294cccb5f96c75fcb71bda1bbc4646367c4ebeda89d7318f).
If you are using Let's Encrypt, the last line is likely to be:
Advertised cert: [CN=Let's Encrypt Authority R3,O=Let's Encrypt,C=US] [444ebd67bb83f8807b3921e938ac9178b882bd50aadb11231f044cf5f08df7ce]
There you have it. Your certificate hash is 444ebd67bb83f8807b3921e938ac9178b882bd50aadb11231f044cf5f08df7ce.
This Go code snippet can also compute the hash of certificates given a .der file.
For DNSCrypt resolvers, the stamp includes the resolver’s public key, ensuring authenticity by design.
For DoH (DNS-over-HTTPS) resolvers, however, authenticity normally depends only on the WebPKI (the set of trusted Certificate Authorities in the system). By default, any trusted CA can issue a valid TLS certificate for your DoH server’s domain.
This is where certificate hashes in stamps come in.
Defends against MITM via extra CAs Many systems have extra root CAs installed by enterprises, antivirus tools, or governments. Without pinning, these CAs can issue certificates for your DoH domain and intercept queries. Including certificate hashes ensures the client only accepts the intended TLS chain.
Prevents silent domain takeover If your DoH hostname changes ownership, the new owner can obtain a valid certificate and impersonate your resolver. Pinning the parent certificate’s hash (ideally a dedicated intermediate CA) ensures the client rejects impostors.
Restores DNSCrypt-level assurance DNSCrypt stamps always bind to a known key. With certificate hashes, DoH stamps gain a similar property: encryption and authenticity.
cc1060d39c8329b62b6fbc7d0d6df9309869b981e7e6392d5cd8fa408f4d80e6444ebd67bb83f8807b3921e938ac9178b882bd50aadb11231f044cf5f08df7cee644ba6963e335fe765cb9976b12b10eb54294b42477764ccb3a3acca3acb2fc9a3a34f727deb9bca51003d9ce9c39f8f27dd9c5242901c2bab1a44e635a0219Port already in use:
# Check what's using port 3000
lsof -i :3000
# Or use a different port
doh-proxy -l 127.0.0.1:3001 ...
Certificate errors:
DNS resolution failures:
dig @127.0.0.1 -p 53 example.comFor high-traffic deployments:
doh-proxy -H 'doh.example.com' \
-u 127.0.0.1:53 \
-c 10000 \ # Max clients
-C 100 \ # Max concurrent streams per client
-t 30 # Timeout in seconds
Compatible DoH clients include:
doh-proxy powers several public DNS services including:
doh.crypto.sx - Public DNS resolverContributions are welcome! Please feel free to submit pull requests or open issues on GitHub.
This project is licensed under the MIT License - see the LICENSE file for details.
1,572 followers · starred Aug 2018
918 followers · starred Nov 2018
17 followers · starred Aug 2025
1,356 followers · starred Aug 2018
Fast, mature, secure DoH and ODoH server proxy written in Rust. Previously known as doh-proxy and rust-doh.
Rust
1,074
391 commits
updated Oct 11, 2026

A fast and secure DoH (DNS-over-HTTPS) and ODoH (Oblivious DoH) server.
doh-proxy is written in Rust, and has been battle-tested in production since February 2018. It doesn't do DNS resolution on its own, but can sit in front of any DNS resolver in order to augment it with DoH support.
Precompiled tarballs and Debian packages for Linux/x86_64 can be downloaded here.
This requires the rust compiler to be installed.
cargo install doh-proxy
cargo install doh-proxy --no-default-features
# Simple setup with a local DNS resolver
doh-proxy -H 'doh.example.com' -u 127.0.0.1:53
# With a specific public IP address
doh-proxy -H 'doh.example.com' -u 127.0.0.1:53 -g 203.0.113.1
# With built-in TLS support
doh-proxy -H 'doh.example.com' -u 127.0.0.1:53 -i /path/to/cert.pem -I /path/to/key.pem
USAGE:
doh-proxy [FLAGS] [OPTIONS]
FLAGS:
-O, --allow-odoh-post Allow POST queries over ODoH even if they have been disabed for DoH
-K, --disable-keepalive Disable keepalive
-P, --disable-post Disable POST queries
-h, --help Prints help information
-V, --version Prints version information
OPTIONS:
-E, --err-ttl <err_ttl> TTL for errors, in seconds [default: 2]
-H, --hostname <hostname> Host name (not IP address) DoH clients will use to connect
-l, --listen-address <listen_address> Address to listen to [default: 127.0.0.1:3000]
-b, --local-bind-address <local_bind_address> Address to connect from
-c, --max-clients <max_clients> Maximum number of simultaneous client connections, TLS handshakes included [default: 512]
-C, --max-concurrent <max_concurrent> Maximum number of concurrent requests per client [default: 16]
-X, --max-ttl <max_ttl> Maximum TTL, in seconds [default: 604800]
-T, --min-ttl <min_ttl> Minimum TTL, in seconds [default: 10]
-p, --path <path> URI path [default: /dns-query]
-g, --public-address <public_address> External IP address(es) DoH clients will connect to (can be specified multiple times)
-j, --public-port <public_port> External port DoH clients will connect to, if not 443
-u, --server-address <server_address> Address to connect to [default: 9.9.9.9:53]
-t, --timeout <timeout> Time budget of a request, in seconds (1 to 3600). Idle connections are closed after this delay, sooner when the server is busy [default: 10]
-I, --tls-cert-key-path <tls_cert_key_path>
Path to the PEM-encoded secret keys (only required for built-in TLS)
-i, --tls-cert-path <tls_cert_path>
Path to the PEM/PKCS#8-encoded certificates (only required for built-in TLS)
--enable-ecs Enable EDNS Client Subnet
--ecs-prefix-v4 <ecs_prefix_v4> IPv4 prefix length for EDNS Client Subnet [default: 24]
--ecs-prefix-v6 <ecs_prefix_v6> IPv6 prefix length for EDNS Client Subnet [default: 56]
Basic setup with custom DNS resolver:
doh-proxy -H 'doh.example.com' -u 8.8.8.8:53 -g 203.0.113.1
Multiple IP addresses for load balancing:
doh-proxy -H 'doh.example.com' -u 127.0.0.1:53 -g 203.0.113.1 -g 203.0.113.2 -g 2001:db8::1
This generates separate DNS stamps for each IP address, allowing clients to connect via any of them.
Production setup with TLS and custom limits:
doh-proxy -H 'doh.example.com' \
-u 127.0.0.1:53 \
-l 0.0.0.0:443 \
-i /etc/letsencrypt/live/doh.example.com/fullchain.pem \
-I /etc/letsencrypt/live/doh.example.com/privkey.pem \
-c 1000 \
-C 32
Behind a reverse proxy (nginx/Caddy):
doh-proxy -H 'doh.example.com' -u 127.0.0.1:53 -l 127.0.0.1:3000
The recommended deployment is behind a TLS termination proxy such as nginx, Caddy, HAProxy, or a CDN. This allows:
Example with nginx:
server {
listen 443 ssl http2;
server_name doh.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location /dns-query {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Example with Caddy:
doh.example.com {
reverse_proxy /dns-query localhost:3000
}
For simpler deployments or when running on separate infrastructure:
doh-proxy -H 'doh.example.com' \
-u 127.0.0.1:53 \
-l 0.0.0.0:443 \
-i /path/to/fullchain.pem \
-I /path/to/privkey.pem
Certificate Requirements:
If using ECDSA certificates that start with -----BEGIN EC PRIVATE KEY-----, convert to PKCS#8:
openssl pkcs8 -topk8 -nocrypt -in example.key -out example.pkcs8.pem
Using Let's Encrypt with acme.sh:
# Install acme.sh
curl https://get.acme.sh | sh
# Get certificates
acme.sh --issue -d doh.example.com --webroot /var/www/html
# Run doh-proxy with Let's Encrypt certificates
doh-proxy -H 'doh.example.com' \
-u 127.0.0.1:53 \
-i ~/.acme.sh/doh.example.com/fullchain.cer \
-I ~/.acme.sh/doh.example.com/doh.example.com.key
Note: Once HTTPS is enabled, HTTP connections will not be accepted. A sample self-signed certificate
localhost.pemis available for testing.
Encrypted DNS Server can handle both DNSCrypt and DoH on the same port:
# In encrypted-dns-server.toml
[tls]
upstream_addr = "127.0.0.1:3000"
This provides:
location /dns-query {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
The example above opens a fresh backend connection for each request.
To enable connection reuse, use an upstream block with keepalive:
upstream doh_backend {
server 127.0.0.1:3000;
keepalive 16;
}
location /dns-query {
proxy_pass http://doh_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_next_upstream error timeout non_idempotent;
}
doh-proxy closes connections that stay idle, like most servers.
If nginx reuses a connection just as it closes, that request attempt can fail.
nginx retries GET requests by default, and proxy_next_upstream ... non_idempotent allows it to retry POST queries as well.
DoH and ODoH queries can safely be repeated.
Retries are only possible before nginx starts sending the response to the client.
Setting keepalive_timeout 5s; in the upstream block can reduce these retries at normal load with the default --timeout 10, but is optional.
Under connection pressure, doh-proxy may close idle connections sooner, so a shorter nginx timeout does not replace retries.
backend doh_backend
mode http
server doh1 127.0.0.1:3000 check
The server supports Google's DNS-over-HTTPS JSON API format, making it compatible with applications that use this format.
Send GET requests to /dns-query with Accept: application/dns-json header:
# Query A records
curl -H "Accept: application/dns-json" \
"http://localhost:3000/dns-query?name=example.com&type=1"
# Query with multiple parameters
curl -H "Accept: application/dns-json" \
"http://localhost:3000/dns-query?name=example.com&type=28&cd=1&do=1"
name - Domain name to query (required)type - DNS record type (default: 1 for A records)cd - Disable DNSSEC validation (0 or 1)do - Request DNSSEC data (0 or 1)edns_client_subnet - Client subnet for EDNS{
"Status": 0,
"TC": false,
"RD": true,
"RA": true,
"AD": false,
"CD": false,
"Question": [{
"name": "example.com",
"type": 1
}],
"Answer": [{
"name": "example.com",
"type": 1,
"TTL": 300,
"data": "93.184.216.34"
}]
}
EDNS Client Subnet (ECS) is a DNS extension that allows the DoH proxy to forward client IP information to upstream DNS resolvers. This enables geo-optimized DNS responses by allowing authoritative nameservers to return results based on the client's actual location rather than the proxy's location.
Enable ECS support with the following command-line options:
--enable-ecs - Enable EDNS Client Subnet functionality--ecs-prefix-v4 <length> - IPv4 prefix length (default: 24)--ecs-prefix-v6 <length> - IPv6 prefix length (default: 56)Client IP Extraction: The proxy extracts the client's IP address from:
X-Forwarded-For header (takes the first IP if multiple are present)X-Real-IP headerIP Truncation: For privacy, client IPs are truncated to the configured prefix length:
DNS Query Enhancement: The truncated client subnet is added to outgoing DNS queries using the EDNS0 Client Subnet option (RFC 7871).
Geo-Optimized Responses: Upstream resolvers and authoritative nameservers can use this information to return geographically appropriate results.
Basic ECS setup:
doh-proxy -H 'doh.example.com' -u 8.8.8.8:53 --enable-ecs
Custom prefix lengths for more privacy:
doh-proxy -H 'doh.example.com' -u 8.8.8.8:53 \
--enable-ecs \
--ecs-prefix-v4 16 \
--ecs-prefix-v6 48
Behind nginx with ECS enabled:
server {
listen 443 ssl http2;
server_name doh.example.com;
location /dns-query {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Testing ECS functionality:
# With X-Forwarded-For header
curl -H "X-Forwarded-For: 1.2.3.4" \
-H "Accept: application/dns-json" \
"https://doh.example.com/dns-query?name=example.com&type=1"
# The DNS query sent to upstream will include ECS: 1.2.0.0/24
For maximum privacy, avoid enabling ECS or use larger prefix values (smaller subnets) like /8 for IPv4 or /32 for IPv6.
Oblivious DoH is similar to Anonymized DNSCrypt, but for DoH. It requires relays, but also upstream DoH servers that support the protocol.
This proxy supports ODoH termination (not relaying) out of the box.
However, ephemeral keys are currently only stored in memory. In a load-balanced configuration, sticky sessions must be used.
Currently available ODoH relays only use POST queries.
So, POST queries have been disabled for regular DoH queries, accepting them is required to be compatible with ODoH relays.
This can be achieved with the --allow-odoh-post command-line switch.
--timeout (10 seconds by default, between 1 and 3600) is the time budget of a single request.
It starts once the request headers have been received, and covers reading the request body, waiting for the upstream resolver, and retrying over TCP when a UDP response was truncated.
A body that doesn't arrive in time gets a 408 response, and a resolver that doesn't answer in time gets a 502.
The same value controls how long connections are kept, so there is nothing else to tune:
--max-clients, idle connections are closed sooner: after half the timeout from 70% of the limit, and after a tenth of it (never less than a second) from 90%.
The delays go back up once the number of connections falls below 80%, and then below 60% of the limit.
Requests in progress are never cut short because of this.Connection: close.
HTTP/2 connections send a GOAWAY frame and complete the streams they had already accepted.
Clients simply open a new connection.With the default timeout, this gives:
+------------------------------------------------+------------+
| Event | Default |
+------------------------------------------------+------------+
| Request budget | 10 s |
| Idle connection closed after | 10 s |
| from 70% / 90% of the connection limit | 5 s / 1 s |
| TLS handshake limit | 10 s |
| from 70% / 90% of the connection limit | 5 s / 2 s |
| Connection retired at the age of | 60 s |
| Retiring connection closed at the latest after | 20 s |
+------------------------------------------------+------------+
--max-clients counts connections from the moment they are accepted, TLS handshakes included.
Connections above the limit are closed right away, before any TLS work.
A proxy that reuses connections to doh-proxy should retry requests that fail because an idle connection was closed at the same moment.
Truncated UDP responses are retried over TCP. The number of simultaneous TCP connections to the upstream resolver is limited separately, to one for every 8 allowed clients (and at least one). A query that needs one waits for a free connection, within its own time budget.
These limits bound how long the server holds on to its resources, but they are a safety net, not a guarantee. Under heavy overload, or with an upstream resolver that stops answering, some queries will still fail.
Use the online DNS stamp calculator to compute the stamp for your server.
Add it to the [static] section of dnscrypt-proxy and check that everything works as expected.
Then, start dnscrypt-proxy with the -show-certs command-line flag to print the hashes for your certificate chain.
Here is an example output:
[NOTICE] Advertised cert: [CN=dohtrial.att.net,O=AT&T Services\, Inc.,L=Dallas,ST=Texas,C=US] [f679e8451940f06141854dc94e1eb79fa5e04463c15b88f3b392da793c16c353]
[NOTICE] Advertised cert: [CN=DigiCert Global CA G2,O=DigiCert Inc,C=US] [f61e576877da9650294cccb5f96c75fcb71bda1bbc4646367c4ebeda89d7318f]
The first printed certificate is the certificate of the server itself. The next line is the one that signed that certificate. As you keep going down, you are getting closer to the certificate authority.
Unless you are using intermediate certificates, your safest option is probably to include the last printed hash certificate in your DNS stamp.
Go back to the online DNS stamp calculator, and copy&paste the hash (in this example: f61e576877da9650294cccb5f96c75fcb71bda1bbc4646367c4ebeda89d7318f).
If you are using Let's Encrypt, the last line is likely to be:
Advertised cert: [CN=Let's Encrypt Authority R3,O=Let's Encrypt,C=US] [444ebd67bb83f8807b3921e938ac9178b882bd50aadb11231f044cf5f08df7ce]
There you have it. Your certificate hash is 444ebd67bb83f8807b3921e938ac9178b882bd50aadb11231f044cf5f08df7ce.
This Go code snippet can also compute the hash of certificates given a .der file.
For DNSCrypt resolvers, the stamp includes the resolver’s public key, ensuring authenticity by design.
For DoH (DNS-over-HTTPS) resolvers, however, authenticity normally depends only on the WebPKI (the set of trusted Certificate Authorities in the system). By default, any trusted CA can issue a valid TLS certificate for your DoH server’s domain.
This is where certificate hashes in stamps come in.
Defends against MITM via extra CAs Many systems have extra root CAs installed by enterprises, antivirus tools, or governments. Without pinning, these CAs can issue certificates for your DoH domain and intercept queries. Including certificate hashes ensures the client only accepts the intended TLS chain.
Prevents silent domain takeover If your DoH hostname changes ownership, the new owner can obtain a valid certificate and impersonate your resolver. Pinning the parent certificate’s hash (ideally a dedicated intermediate CA) ensures the client rejects impostors.
Restores DNSCrypt-level assurance DNSCrypt stamps always bind to a known key. With certificate hashes, DoH stamps gain a similar property: encryption and authenticity.
cc1060d39c8329b62b6fbc7d0d6df9309869b981e7e6392d5cd8fa408f4d80e6444ebd67bb83f8807b3921e938ac9178b882bd50aadb11231f044cf5f08df7cee644ba6963e335fe765cb9976b12b10eb54294b42477764ccb3a3acca3acb2fc9a3a34f727deb9bca51003d9ce9c39f8f27dd9c5242901c2bab1a44e635a0219Port already in use:
# Check what's using port 3000
lsof -i :3000
# Or use a different port
doh-proxy -l 127.0.0.1:3001 ...
Certificate errors:
DNS resolution failures:
dig @127.0.0.1 -p 53 example.comFor high-traffic deployments:
doh-proxy -H 'doh.example.com' \
-u 127.0.0.1:53 \
-c 10000 \ # Max clients
-C 100 \ # Max concurrent streams per client
-t 30 # Timeout in seconds
Compatible DoH clients include:
doh-proxy powers several public DNS services including:
doh.crypto.sx - Public DNS resolverContributions are welcome! Please feel free to submit pull requests or open issues on GitHub.
This project is licensed under the MIT License - see the LICENSE file for details.
1,572 followers · starred Aug 2018
918 followers · starred Nov 2018
17 followers · starred Aug 2025
1,356 followers · starred Aug 2018