How-To

How to Set Up DNS-over-HTTPS with Pi-hole and Unbound (2026 Guide)

16 min read

If you caught the “DOOM over DNS” story on Hacker News, you know DNS queries can carry more than domain names. Someone rendered game frames through TXT records. Fun trick, but it’s also a reminder: DNS is a wide-open channel almost nobody thinks about. Every device on your network, including your laptop, phone, and that smart bulb that phones home for no reason, sends lookups in plain text. Your ISP can read every one of them unless you do something about it.

This guide shows you how to close that gap with Pi-hole, Unbound, and DNS-over-HTTPS (DoH). Just as important, it shows you how to prove to yourself it’s actually working, instead of trusting a checkbox in a settings page.

What DNS-over-HTTPS Actually Protects Against

Plain DNS (port 53) sends queries and responses unencrypted. Anyone on the path, whether your ISP, a compromised router, or someone on the same coffee shop Wi-Fi, can read every domain you look up. Some of them can tamper with the response too, redirecting you somewhere malicious. DoH wraps that traffic in TLS, the same encryption HTTPS uses for websites. It blends in with regular web traffic and can’t be read or altered in transit.

What it does not do is make your queries invisible to everyone. Whoever operates the DoH endpoint, whether Cloudflare, Quad9, or whoever you pick, still sees every domain you resolve, the same way your ISP used to. DoH only protects the DNS lookup itself. Your ISP can still see the destination IP addresses your traffic connects to. It also sees metadata: connection timing, TLS SNI fields (unless you use encrypted SNI too), and traffic volume. DoH narrows the surveillance window. It doesn’t close it.

The Architecture: Client → Pi-hole → Unbound → Encrypted Upstream

This is where most tutorials get sloppy, so here’s exactly the chain you’re building:

  • Client device (laptop, phone, IoT gadget) sends a plain DNS query on port 53 to Pi-hole, because that’s the DNS server your router hands out via DHCP.
  • Pi-hole checks the query against its blocklists. If it’s an ad or tracker domain, it returns 0.0.0.0 and stops there. Everything else gets forwarded to its configured upstream resolver.
  • Unbound, running locally on port 5335, receives that forwarded query. Unbound is a recursive resolver. Its job is either to walk the DNS hierarchy itself (root servers → TLD servers → authoritative servers) or to forward the query somewhere else, depending on how you configure it.
  • The encrypted upstream hop is where DoH actually lives. Unbound hands the query to a local DoH-forwarding proxy, which wraps it in HTTPS and sends it to a DoH-capable provider over port 443.

The nuance: Unbound doing full recursion (talking directly to authoritative nameservers) is already a privacy win. No single company sees your entire query history. But almost no authoritative nameserver speaks DoH. If you want encrypted transport, you have to give up pure recursion and forward everything to one DoH-capable resolver instead. That’s a real trade-off. Full recursion spreads trust across the DNS hierarchy but travels in plaintext for the final hops. DoH forwarding encrypts everything but concentrates trust, and visibility, in whichever provider you pick. This guide builds the forwarding version, since that’s what actually gets you encryption.

Correcting a Common Misconception: Unbound Does Not Speak DoH Natively

You’ll find plenty of older posts that casually say “just enable DoH in Unbound.” That’s wrong. Unbound has no built-in support for DNS-over-HTTPS. It handles plaintext DNS and, in newer builds, DNS-over-TLS (DoT) via forward-tls-upstream. It doesn’t handle the HTTP/2-wrapped DoH protocol. To get DoH, you need a separate local helper that speaks DoH on Unbound’s behalf, with Unbound forwarding to it over plain loopback traffic.

For years, the go-to helper was Cloudflare’s cloudflared daemon running in proxy-dns mode. Pi-hole’s own documentation used to recommend it. Cloudflare deprecated the proxy-dns mode in November 2025, which broke that setup for anyone who hadn’t already migrated. Annoying timing if you’d just finished following an old guide. If you’re following one that references cloudflared --proxy-dns, skip it, check Pi-hole’s current cloudflared guide before assuming it still applies. This tutorial uses dnscrypt-proxy instead. It’s actively maintained, speaks DoH natively as a client, and isn’t tied to a single vendor’s roadmap.

Before You Begin

Nothing exotic on this list, but skip a box and you’ll be debugging blind later. Make sure you have:

  • An existing, working Pi-hole installation (this guide assumes Pi-hole is already installed and blocking ads; it covers adding Unbound and DoH on top, not the initial Pi-hole setup)
  • Root or sudo access on the Pi-hole host over SSH
  • A Debian/Ubuntu-based Linux host (Raspberry Pi OS, Ubuntu Server, Debian); this guide was tested on Ubuntu 24.04 LTS with Pi-hole v6.x
  • At least 512 MB of free RAM (Unbound and dnscrypt-proxy are lightweight, but don’t try this on a Pi Zero running other services too)
  • A browser on any device on the same network to reach the Pi-hole admin dashboard
  • Basic comfort editing config files with nano or vim
RequirementDetails
Host OSDebian 12, Ubuntu 22.04/24.04 LTS, or Raspberry Pi OS Bookworm
Pi-hole versionv6.x (dashboard paths shown here match v6’s admin UI)
Unbound version1.19.x or later (from distro repos)
DoH helperdnscrypt-proxy 2.1.x
Accesssudo on the Pi-hole host; browser access to http://pi.hole/admin

Step-by-Step Guide

Step 1: Confirm Your Existing Pi-hole Install Is Healthy

Before touching anything, verify Pi-hole is running and note its version.

pihole -v

Pi-hole version is v6.1.2 (Latest: v6.1.2)
FTL version is v6.1.1 (Latest: v6.1.1)
Web version is v6.1 (Latest: v6.1)

If this errors out, stop and fix your Pi-hole install first. Everything downstream depends on it working.

Step 2: Install Unbound

sudo apt update
sudo apt install -y unbound

Setting up unbound (1.19.1-1) …
Created symlink /etc/systemd/system/multi-user.target.wants/unbound.service → /lib/systemd/system/unbound.service.

Confirm the install:

unbound -V | head -n 1

Version 1.19.1

Step 3: Add Pi-hole’s Recommended Unbound Configuration

Pi-hole publishes a config block tuned for running Unbound as a local, loopback-only resolver. Create a dedicated config file instead of editing unbound.conf directly, Unbound automatically pulls in anything under /etc/unbound/unbound.conf.d/.

sudo nano /etc/unbound/unbound.conf.d/pi-hole.conf

Paste the following:

# /etc/unbound/unbound.conf.d/pi-hole.conf
server:
    verbosity: 0
    interface: 127.0.0.1
    port: 5335
    do-ip4: yes
    do-udp: yes
    do-tcp: yes
    do-ip6: no
    prefer-ip6: no

    # Path to the current root server list (fetched in Step 4)
    root-hints: "/var/lib/unbound/root.hints"

    # Trust glue records only within the server's own authority
    harden-glue: yes

    # Reject responses missing expected DNSSEC data instead of silently allowing them
    harden-dnssec-stripped: yes

    # Leave off — 0x20 capitalization randomization sometimes breaks DNSSEC validation
    use-caps-for-id: no

    # Keep the EDNS buffer small enough to avoid fragmentation issues
    edns-buffer-size: 1232

    # Refresh cache entries before they expire, reduces perceived latency
    prefetch: yes
    num-threads: 1
    so-rcvbuf: 1m

    # Don't leak RFC1918 / link-local addresses upstream
    private-address: 192.168.0.0/16
    private-address: 169.254.0.0/16
    private-address: 172.16.0.0/12
    private-address: 10.0.0.0/8
    private-address: fd00::/8
    private-address: fe80::/10

Tip: interface: 127.0.0.1 and port: 5335 are what make this a local-only resolver; nothing outside the host can query it, and it doesn’t conflict with Pi-hole’s own listener on port 53.

Step 4: Download the Current Root Hints File

Unbound needs an up-to-date list of root DNS servers to bootstrap recursive lookups.

sudo mkdir -p /var/lib/unbound
wget https://www.internic.net/domain/named.root -qO- | sudo tee /var/lib/unbound/root.hints > /dev/null
sudo chown unbound:unbound /var/lib/unbound/root.hints

Root servers don’t change often. Refresh this file every few months anyway. Set a calendar reminder or a cron job, whichever you’ll actually keep.

Step 5: Restart and Test Unbound

sudo service unbound restart
dig pi-hole.net @127.0.0.1 -p 5335

; <<>> DiG 9.18.28-1ubuntu0.3-Ubuntu <<>> pi-hole.net @127.0.0.1 -p 5335
;; ANSWER SECTION:
pi-hole.net. 86400 IN A 3.18.128.71

;; Query time: 187 msec
;; SERVER: 127.0.0.1#5335(127.0.0.1)

A Query time in the 100–300 ms range on the first lookup is normal. That’s Unbound walking the recursion chain. Run the same command again and it should drop to single digits thanks to caching.

Terminal window showing a successful `dig pi-hole.net @127.0.0.1 -p 5335` command with the ANSWER SECTION and query time visible, confirming Unbound is resolving on port 5335

If this fails, stop here and check the Troubleshooting section before moving on. Everything after this step assumes Unbound is answering correctly.

Step 6: Choose Full Recursion or DoH Forwarding

At this point, Unbound is already resolving recursively using the root hints. That’s a working, privacy-improved setup on its own, and some admins stop right here. But recursive lookups to authoritative nameservers happen in plaintext, because almost no authoritative server supports DoH or DoT. This guide’s goal is to encrypt DNS traffic leaving your network. That means switching Unbound from full recursion to forwarding everything through a local DoH proxy. The next few steps do exactly that.

Trade-off to keep in mind: forwarding to a single DoH provider re-centralizes trust. That provider sees every domain you look up, same as your ISP used to. You’re trading “many parties see fragments” for “one party sees everything, but nobody in between can read or tamper with it.” For most home networks, that’s a reasonable trade, but it’s worth being honest about.

Step 7: Install dnscrypt-proxy

sudo apt install -y dnscrypt-proxy

Setting up dnscrypt-proxy (2.1.5-1) …
Created symlink /etc/systemd/system/multi-user.target.wants/dnscrypt-proxy.service → /lib/systemd/system/dnscrypt-proxy.service.

Step 8: Configure dnscrypt-proxy for DoH-Only Forwarding

Edit the main config file:

sudo nano /etc/dnscrypt-proxy/dnscrypt-proxy.toml

Find and change these values (they’re scattered through the file; use Ctrl+W in nano to jump to each key):

# /etc/dnscrypt-proxy/dnscrypt-proxy.toml

# Listen only on loopback, on a port that won't collide with Pi-hole's FTL on 53
listen_addresses = ['127.0.0.1:5053']

# Restrict to DoH-capable resolvers only
dnscrypt_servers = false
doh_servers = true
ipv4_servers = true
ipv6_servers = false

# Unbound already validates DNSSEC — don't duplicate the check here
require_dnssec = false

# Pick one or more DoH providers by name from the built-in resolver list
server_names = ['cloudflare', 'quad9-doh-ip4-nofilter-pri']

# Used once, in plaintext, to fetch the current resolver list — after that,
# all real queries go out over DoH
bootstrap_resolvers = ['9.9.9.9:53', '1.1.1.1:53']

Note: That bootstrap_resolvers entry is the one plaintext DNS query dnscrypt-proxy makes on startup, purely to download its list of known DoH providers. It’s not used for your actual lookups.

Step 9: Start and Verify dnscrypt-proxy

sudo systemctl restart dnscrypt-proxy
sudo systemctl enable dnscrypt-proxy
sudo systemctl status dnscrypt-proxy

● dnscrypt-proxy.service – DNSCrypt-proxy client
Loaded: loaded (/lib/systemd/system/dnscrypt-proxy.service; enabled)
Active: active (running) since Mon 2026-08-24 09:12:03 UTC; 4s ago
Main PID: 18422 (dnscrypt-proxy)

Terminal output of `systemctl status dnscrypt-proxy` showing the service in an active (running) state with the main PID visible

Test it directly:

dig pi-hole.net @127.0.0.1 -p 5053

;; ANSWER SECTION:
pi-hole.net. 300 IN A 3.18.128.71
;; SERVER: 127.0.0.1#5053(127.0.0.1)

If you get an answer here, the DoH proxy itself is working correctly.

Step 10: Point Unbound at the DoH Proxy

Now tell Unbound to forward all queries to dnscrypt-proxy instead of doing its own recursion.

sudo nano /etc/unbound/unbound.conf.d/forward-doh.conf
# /etc/unbound/unbound.conf.d/forward-doh.conf
forward-zone:
    name: "."
    forward-addr: 127.0.0.1@5053
    forward-first: no

Restart Unbound and re-test:

sudo service unbound restart
dig pi-hole.net @127.0.0.1 -p 5335

The response should look the same as before, but now the query path is Unbound → dnscrypt-proxy → HTTPS → DoH provider, instead of Unbound walking the root hierarchy directly.

Step 11: Point Pi-hole at Unbound

Open the Pi-hole admin dashboard and log in.

Go to Settings > DNS.

Under Upstream DNS Servers, uncheck any public providers currently selected (Google, Cloudflare, etc.). Check Custom 1 (IPv4) and enter:

127.0.0.1#5335

Pi-hole admin dashboard Settings > DNS tab showing the Upstream DNS Servers section with public provider checkboxes unchecked and the Custom 1 (IPv4) field filled in with 127.0.0.1#5335

Scroll down to the DNSSEC section and uncheck Use DNSSEC. Unbound is already validating DNSSEC (that’s what harden-dnssec-stripped: yes does). Leaving Pi-hole’s own DNSSEC checkbox on means two independent validators can disagree. You’ll get sporadic SERVFAIL errors on domains that should resolve fine.

Pi-hole DNS settings page scrolled to the Use DNSSEC checkbox, shown unchecked since Unbound already handles DNSSEC validation

Click Save & Apply.

Step 12: Confirm Pi-hole Is Resolving Through the New Chain

Run a query from any device on your network (or from the Pi-hole host itself) and check the query log.

nslookup github.com

Then open the dashboard’s query log:

Pi-hole Query Log page showing several recently resolved DNS queries after switching the upstream resolver to Unbound, with status shown as forwarded/cached

Queries should show a Status of “Forwarded” (first lookup) or “Cached” (repeat lookups). They resolve at normal speed once the first request warms the cache.

Step 13: Verify Encryption With a Packet Capture

This is the step most guides skip, probably because it takes five extra minutes and nobody wants to open Wireshark on a Saturday. But it’s the only way to actually prove the traffic leaving your network is encrypted, instead of taking the setup on faith.

Install tcpdump if it’s not already present:

sudo apt install -y tcpdump

First, confirm no plaintext DNS is leaving the host. From a second terminal, trigger a fresh lookup (pick a domain you haven’t queried recently so it isn’t cached):

sudo tcpdump -i eth0 -n port 53

listening on eth0, link-type EN10MB (Ethernet), capture size 262144 bytes

You want to see no output here (aside from possibly local loopback noise) while a query resolves. That confirms nothing is going out over plaintext port 53 to the internet. Stop the capture with Ctrl+C.

Now confirm the encrypted hop is actually happening. Find the IP your DoH provider resolves to. For Cloudflare’s DoH endpoint, that’s typically 104.16.248.249 or 104.16.249.249. Then capture on port 443:

sudo tcpdump -i eth0 -n port 443 and host 104.16.249.249

09:41:02.883112 IP 192.168.1.10.51422 > 104.16.249.249.443: Flags [S], seq 1021847, win 64240
09:41:02.911455 IP 104.16.249.249.443 > 192.168.1.10.51422: Flags [S.], seq 3820194
09:41:02.947023 IP 192.168.1.10.51422 > 104.16.249.249.443: Flags [P.], length 517

That TLS handshake and encrypted Application Data traffic on port 443, with nothing readable in the payload, is your proof. For a more visual confirmation, run the equivalent capture in Wireshark with the filter tcp.port == 443 && ip.addr == 104.16.249.249. Compare it against a capture of plain DNS traffic on udp.port == 53. The plaintext capture shows queried domain names right in the packet detail pane. The DoH capture shows only TLS record types.

Wireshark capture window with a display filter for tcp.port==443 showing encrypted TLS Application Data packets to the DoH provider's IP, positioned next to or compared against a plaintext udp.port==53 capture showing readable domain names

If step 13 shows plaintext port 53 traffic leaving the host, something upstream of Unbound is still misconfigured. Go back to Step 11 and re-check the Pi-hole upstream setting.

Configuration Reference

SettingLocationRecommended ValueWhy
Unbound listen port/etc/unbound/unbound.conf.d/pi-hole.conf5335Avoids conflict with Pi-hole’s FTL listener on port 53
dnscrypt-proxy listen port/etc/dnscrypt-proxy/dnscrypt-proxy.toml127.0.0.1:5053Loopback-only, separate from both Pi-hole and Unbound
Unbound forward target/etc/unbound/unbound.conf.d/forward-doh.conf127.0.0.1@5053Routes all recursive queries through the DoH proxy
Pi-hole Custom 1 (IPv4)Dashboard: Settings > DNS127.0.0.1#5335Points Pi-hole at Unbound instead of a public resolver
Pi-hole “Use DNSSEC”Dashboard: Settings > DNSUncheckedPrevents double-validation conflicts with Unbound
dnscrypt-proxy require_dnssecdnscrypt-proxy.tomlfalseLeaves DNSSEC validation solely to Unbound

What DNS-over-HTTPS Doesn’t Protect You From

Worth repeating plainly: it’s easy to walk away from a setup like this thinking you’ve gone dark. DoH encrypts the DNS lookup. It does not:

  • Hide your traffic from the DoH provider itself. Cloudflare or Quad9 now sees what your ISP used to see. Read their privacy policies if that matters to you. Quad9 and Cloudflare both publish no-logging commitments, but you’re trusting a policy, not a technical guarantee.
  • Hide destination IP addresses from your ISP. Once your browser knows a site’s IP from the DNS answer, it connects to that IP directly. That connection is visible at the network level, regardless of how the DNS lookup was encrypted.
  • Hide the TLS SNI field in the subsequent HTTPS connection. It often still leaks the hostname you’re connecting to, unless the site and your browser both support Encrypted Client Hello (ECH).
  • Protect against malware or compromised devices on your own network. Pi-hole’s blocklists help here, but DoH is a transport-layer protection, not a content filter.

This setup closes one specific window: DNS eavesdropping and tampering. That’s it.

Tips and Troubleshooting

The usual suspects, in order of likelihood:

DNS resolution stops working entirely after switching upstream

Why it happens: Pi-hole is still pointed at the old upstream, or one of the local services (Unbound or dnscrypt-proxy) isn’t actually listening on the port you configured.

Fix: Confirm the Settings > DNS page shows only Custom 1 (IPv4) checked with 127.0.0.1#5335. Then check both services from the terminal:

sudo systemctl status unbound dnscrypt-proxy
sudo ss -lntup | grep -E '5335|5053'

udp UNCONN 0 0 127.0.0.1:5335 0.0.0.0:* users:((“unbound”,pid=1822,fd=6))
udp UNCONN 0 0 127.0.0.1:5053 0.0.0.0:* users:((“dnscrypt-proxy”,pid=1901,fd=4))

If either port is missing from that output, the corresponding service failed to bind. Check its logs with journalctl -u unbound -n 50 or journalctl -u dnscrypt-proxy -n 50.

Unbound fails to start or won’t respond on port 5335

Why it happens: A syntax error in pi-hole.conf, or root.hints is missing/unreadable by the unbound user.

Fix: Validate the config syntax before restarting:

sudo unbound-checkconf

unbound-checkconf: no errors in /etc/unbound/unbound.conf

If it reports an error, it’ll point to the exact file and line. Also confirm ownership on the root hints file:

ls -l /var/lib/unbound/root.hints

-rw-r–r– 1 unbound unbound 3212 Aug 24 09:00 /var/lib/unbound/root.hints

dnscrypt-proxy won’t start, or worked before and suddenly stopped

Why it happens: Most commonly this is a leftover config from an old cloudflared --proxy-dns setup that’s still running and holding the port. Or it’s a dnscrypt-proxy.toml edit with a typo in a TOML array: missing bracket, stray comma.

Fix: Check for a conflicting cloudflared service first. Cloudflare deprecated proxy-dns mode in November 2025, so if you’re migrating from an older setup, disable it explicitly:

sudo systemctl stop cloudflared
sudo systemctl disable cloudflared

Then validate your TOML syntax by checking the service logs for the exact parse error:

journalctl -u dnscrypt-proxy -n 30 --no-pager

Queries return SERVFAIL or DNSSEC validation errors

Why it happens: Both Unbound and Pi-hole are independently trying to validate DNSSEC, and they disagree. Usually that’s because Pi-hole’s “Use DNSSEC” checkbox is still on.

Fix: Go back to Settings > DNS in the dashboard and confirm Use DNSSEC is unchecked. Also confirm require_dnssec = false in dnscrypt-proxy.toml, since Unbound should be the only validator in the chain.

Upstream timeouts or slow first lookups

Why it happens: dnscrypt-proxy’s HTTPS connection to the DoH provider is being blocked or rate-limited, often by an overly aggressive local firewall rule.

Fix: Confirm outbound port 443 isn’t blocked for the Pi-hole host specifically:

sudo ufw status verbose

Make sure there’s no rule denying outbound traffic on 443. Also test the DoH proxy in isolation (Step 9’s dig command) to rule out Unbound as the source of the delay.

Wrapping Up

Once this is running, every device on your network still gets Pi-hole’s ad-blocking. The difference: DNS traffic leaving your router now travels encrypted over HTTPS, instead of sitting in plaintext for your ISP to log. That’s a real, measurable privacy win for a homelab. It’s satisfying to prove it with a packet capture instead of just trusting a settings toggle.

My honest take: it’s worth the 30–40 minutes, but don’t oversell it to yourself as anonymity. Your DoH provider still sees your queries, and your ISP still sees your IP-level traffic. If you want ad-blocking without building your own resolver chain, AdGuard Home bundles DoH upstream support natively. You lose the “trust nobody” architecture of running your own Unbound instance, but you get a chunk of your afternoon back.

StepActionApplies To
1–2Verify Pi-hole, install UnboundBase setup
3–5Configure and test Unbound on port 5335Local resolver
6–9Install and configure dnscrypt-proxy for DoHEncrypted forwarding
10Forward Unbound to the DoH proxyResolver chain
11–12Point Pi-hole at Unbound, verify query logDashboard config
13Packet capture to confirm encryptionVerification

Resources