My own DNS resolver for my phone
I used NextDNS on my phone for ad blocking. It works well. But I already pay for a VPS, so I moved the job to AdGuard Home on that VPS.
The shape
- AdGuard Home runs in Docker.
- DNS-over-TLS listens on port 853. Android’s “Private DNS” setting uses it.
- DNS-over-HTTPS goes through Caddy at
/dns-query. - Plain port 53 is not published.
That last point matters most. A public resolver on port 53 becomes an open resolver. Open resolvers get abused for amplification attacks. DoT and DoH need a TLS handshake first, which makes them poor amplifiers.
Certificates
Caddy already gets a certificate for the hostname. AdGuard reads the same files from Caddy’s data directory for DoT. So there is only one certificate to renew.
I am not sure AdGuard reloads a renewed certificate on its own. I have not tested it. So a weekly timer restarts the container. It costs a few seconds of DNS downtime once a week.
The admin page
The DoH path stays open, because DNS clients cannot log in. The admin UI sits behind my single sign-on page (see One VPS, one login).
dns.example.com {
handle /dns-query* {
reverse_proxy adguard:80
}
handle {
import sso
reverse_proxy adguard:80
}
}
Block lists
- AdGuard DNS filter
- HaGeZi Multi PRO
- HaGeZi Threat Intelligence Feeds, medium
I added a few rules of my own for my phone vendor’s ad domains and some analytics hosts.
Against NextDNS
I tested both from my laptop on the same day. This is one sample, not a benchmark.
| AdGuard on my VPS | NextDNS | |
|---|---|---|
| Warm query latency | ~32 ms | ~47 ms |
| Blocked, of 31 test hosts | 25 | 26 |
So: slightly faster for me, and slightly less strict. The VPS is in the same country as me, which likely explains the latency.
Monitoring
If the resolver dies, my phone stops resolving names. That is a bad failure, so a timer on my laptop checks DoT and DoH every two minutes. After two failures in a row it sends me a Telegram message. It sends another one on recovery.
A gotcha for the check: AdGuard’s DoH endpoint wants the wire format
(?dns=<base64> with Accept: application/dns-message). The JSON form
(?name=) returns 400.