Contents

DNS Over HTTPS at Home: Running Your Own DoH Resolver

Stop leaking every domain you visit to whoever runs your router's resolver

Contents

Plain DNS is the last great open postcard of the internet. You’ve encrypted your web traffic with TLS, you’ve got a VPN you trust, you’ve hardened SSH to within an inch of its life — and then your machine cheerfully shouts every single domain name you look up, in clear text, over UDP port 53, to whatever resolver your ISP handed you at DHCP time. Anyone on the path can read it, and plenty of middleboxes do.

DNS over HTTPS (DoH) fixes the on-the-wire bit by wrapping your queries in ordinary HTTPS. The usual advice is “just point your browser at Cloudflare or Google”, which works, but it swaps one party who can see all your lookups (your ISP) for another (a giant ad-and-cloud company). The more interesting move is to run the DoH resolver yourself, so the encryption terminates on your hardware and you decide who, if anyone, your queries go to upstream.

The architecture I actually run

Advertisement

There are two jobs here and it’s worth keeping them separate in your head:

  1. A recursive/forwarding resolver that does the actual DNS work and ideally adds ad-blocking. I use Unbound, often behind Pi-hole.
  2. A DoH front-end that accepts encrypted HTTPS queries from clients and hands the decrypted query to that resolver.

The tidiest tool for job two is cloudflared in proxy-dns mode, or dnscrypt-proxy. I’ll show cloudflared because it’s a single binary and configures in three lines.

Unbound as the local resolver

First, a small Unbound config that listens only on localhost and does proper recursion with DNSSEC validation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# /etc/unbound/unbound.conf.d/local.conf
server:
    interface: 127.0.0.1
    port: 5335
    do-ip6: no
    harden-dnssec-stripped: yes
    harden-glue: yes
    qname-minimisation: yes
    prefetch: yes
    cache-min-ttl: 300
    access-control: 127.0.0.0/8 allow

qname-minimisation is the privacy win here: Unbound only tells each authoritative server the part of the name it actually needs, instead of leaking the full hostname to the root and TLD servers. The choice of port 5335 rather than 53 is deliberate — it keeps Unbound out of the way of anything else that wants the standard DNS port (Pi-hole, in particular, expects to own 53), and it makes the “who is actually answering” question unambiguous when you’re debugging later.

If you want ad-blocking in the chain — and most homelabbers do — slot Pi-hole in front of Unbound as the forwarder. Pi-hole listens on 53, applies its blocklists, and forwards cache misses to Unbound on 5335; Unbound does the recursion and DNSSEC. The DoH front-end then points at Pi-hole rather than Unbound directly, so every encrypted query also gets filtered. This is the same “compose small single-purpose services into a chain” instinct that runs through most of what running a blog on your own infrastructure teaches you: each box does one job, and you can reason about — and break — them independently.

A DoH endpoint with cloudflared

Advertisement

Now the front-end. This makes cloudflared listen for DoH on a local port and forward the plaintext query to Unbound:

1
2
3
4
5
6
7
$ cloudflared proxy-dns \
    --address 127.0.0.1 \
    --port 5553 \
    --upstream https://127.0.0.1:5335 \
    --max-upstream-conns 5
INF Adding DNS upstream url=https://127.0.0.1:5335
INF Starting DNS over HTTPS proxy server address=https://127.0.0.1:5553/dns-query

Running that command in a terminal is fine for a test, but you don’t want it dying the moment your SSH session closes. Make it a proper systemd service so it starts at boot and restarts if it falls over:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# /etc/systemd/system/cloudflared-doh.service
[Unit]
Description=cloudflared DoH proxy
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/cloudflared proxy-dns \
    --address 127.0.0.1 --port 5553 \
    --upstream https://127.0.0.1:5335 --max-upstream-conns 5
Restart=on-failure
RestartSec=5
DynamicUser=yes
NoNewPrivileges=yes

[Install]
WantedBy=multi-user.target

Then systemctl enable --now cloudflared-doh. The DynamicUser and NoNewPrivileges lines are cheap hardening — the proxy doesn’t need a real account or the ability to escalate, so don’t give it either. That defence-in-depth reflex is the same one I apply when hardening any Linux server from scratch: grant a service the least it needs to do its job and nothing more.

To expose it on your network with a real certificate, put a reverse proxy in front. A Caddy block does this in about as few lines as anything can:

1
2
3
doh.example.com {
    reverse_proxy /dns-query 127.0.0.1:5553
}

Caddy fetches and renews the Let’s Encrypt cert automatically, so clients see a clean https://doh.example.com/dns-query endpoint.

Pointing clients at it

Firefox makes this trivial — Settings → Privacy → enable DNS over HTTPS → “Custom” → paste your URL. On a whole-OS basis, systemd-resolved (recent versions) and Android’s “Private DNS” can use it too, though Android wants DoT (port 853), not DoH, so keep that in mind.

To prove it works, query the endpoint by hand with curl using the DoH wire format:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ curl -sH 'accept: application/dns-json' \
    'https://doh.example.com/dns-query?name=vo.rs&type=A' | jq .Answer
[
  {
    "name": "vo.rs",
    "type": 1,
    "TTL": 300,
    "data": "192.168.1.50"
  }
]

If that returns an answer, your resolver is decrypting, recursing, and replying — all on hardware you control.

What this does and doesn’t buy you

Be clear-eyed about the threat model. DoH stops the network path from your devices to your resolver from snooping or tampering. It does not hide your lookups from your upstream — and if you forward to Cloudflare, you’re back to trusting Cloudflare. The genuinely private setup is Unbound doing full recursion (no forwarder), so your queries fan out across the authoritative hierarchy rather than passing through one company.

It also doesn’t hide which sites you visit from anyone watching your IP traffic, because TLS SNI and the destination IP still give the game away. DoH is one layer, not a cloak. Encrypted Client Hello (ECH) is the piece that finally hides SNI, and it’s rolling out, but it depends on the destination supporting it and on you using DoH to fetch the ECH keys in the first place — so even there, encrypted DNS is the foundation the SNI protection is built on, not a replacement for it. The destination IP itself remains visible regardless; if two services share a CDN that’s some cover, but it isn’t anonymity.

DoH or DoT — and why I run DoH

There are two ways to encrypt DNS on the wire, and the difference is more political than technical. DNS-over-TLS (DoT) runs on its own dedicated port, 853. That makes it clean and easy to reason about — but also trivial for a network operator to spot and block, because anything on 853 is unambiguously encrypted DNS. DoH deliberately rides inside ordinary HTTPS on 443, indistinguishable from any other web traffic, which is precisely why browser vendors favoured it and why some network administrators dislike it: you cannot block it without blocking the web. For a home setup the practical upshot is simple. If your clients are browsers or anything that needs to traverse a hostile network, DoH’s camouflage is the point. If your clients are Android phones, note that Android’s “Private DNS” feature speaks DoT, not DoH, so you’ll want to expose port 853 as well if you want phones using your resolver off-network. Running both front-ends against the same Unbound backend is entirely reasonable; the resolver doesn’t care how the query arrived.

Why terminate the encryption on your own box at all

It’s worth restating the core argument, because “just use Cloudflare’s resolver” is genuinely good advice for most people and I don’t want to oversell the DIY route. The reason to do it yourself is singular: trust consolidation. Every DNS query you make reveals intent — the site you’re about to visit, the service you’re about to use, the company you’re about to research — before a single packet of actual content flows. Handing that stream to one organisation, however well-behaved, means one organisation can build a complete map of your interests over time. Running your own recursive resolver with no forwarder means no single party sees the whole picture; your queries fan out across the authoritative hierarchy, each server learning only the fragment it’s responsible for. That’s a meaningfully different privacy posture, and it’s the one thing a third-party resolver, encrypted or not, can never give you. Whether that difference justifies the maintenance is the question the verdict answers.

When it breaks (and it will)

DNS occupies a special place in the failure hierarchy: when it goes, everything appears broken at once, and the symptoms point everywhere except the real cause. A few specific failure modes are worth committing to memory before they ambush you at an inconvenient hour.

The DNSSEC self-inflicted outage. With harden-dnssec-stripped: yes, Unbound will refuse to return answers for a zone whose signatures don’t validate. Usually that’s working as intended — but if your system clock has drifted badly, every signature looks expired or not-yet-valid, and resolution dies wholesale. The tell is that direct IP connections work fine while every hostname fails. Check timedatectl first; a broken NTP setup masquerades as a broken resolver more often than anyone admits.

The bootstrap chicken-and-egg. cloudflared needs to resolve its own upstream the first time it starts, and if your only resolver is the one it’s trying to provide, it can’t start. Keep a plain fallback (a public resolver address, not a hostname) configured at the OS level so the box can always bootstrap, then let DoH take over once it’s up. Pointing a machine exclusively at a resolver that machine is responsible for hosting is a classic way to lock yourself out after a reboot.

Verify the layers independently. When the chain misbehaves, don’t guess which link failed — test each one in isolation. Query Unbound directly with dig @127.0.0.1 -p 5335 vo.rs; if that works, the resolver is fine and the problem is in the DoH front-end or the proxy. If it doesn’t, you’ve isolated the fault to Unbound and never need to touch the encryption layer. This bottom-up bisection turns “the internet is down” into a two-minute diagnosis.

The confusing-failure tax. Even with all that, a wrong port or an expired cert manifests as “the whole internet is down,” and you’ll spend twenty minutes blaming everything but DNS, as is tradition. Budget for it. The single best habit is to keep one known-good fallback resolver one config line away, so that “is it DNS?” can be answered in seconds rather than during a full-blown panic.

Verdict

Running your own DoH resolver is worth it if you genuinely care about who sees your lookups and you’re willing to own the failure modes. The Unbound-plus-cloudflared combo is maybe an hour of work and gives you encrypted, validating, ad-filtered DNS that terminates on your own box. If you just want browser-level encryption and don’t mind Cloudflare reading your queries, the built-in Firefox toggle is two clicks and you should stop reading. For everyone in between — homelabbers who want the real thing without a weekend project — this is the sweet spot.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.