As an alternative to protecting a public AdGuard Home DoH server with a secret token that is validated by Caddy web server, this approach uses Cloudflare’s tunnel product to do the validation and replaces Caddy. If you are in the Cloudflare environment this approach is arguably more secure and scalable. It also has the benefit of having the DoH server answering on IPv4 and IPv6 even if the DoH server only exposes an public IPv6 address.
DNS over HTTPS via Cloudflare Tunnel + AdGuard Home
Token-authenticated DoH on a Raspberry Pi without port forwarding
Architecture
Cloudflare Tunnel replaces Caddy as the ingress layer. The tunnel makes an outbound-only connection from the Pi to Cloudflare’s edge — no port forwarding is required. Token enforcement and path rewriting happen at the Cloudflare edge before traffic reaches the Pi.
iOS / macOS client
↓ https://DoHserver-hostname/secret-token/dns-query
Cloudflare edge
WAF Custom Rule: block if URI does not contain token (returns 404)
Transform Rule: rewrite /secret-token/dns-query → /dns-query
↓ /dns-query + CF-Connecting-IP header
Cloudflare Tunnel (outbound from Pi, no port forwarding needed)
↓ https://localhost:443
AdGuard Home (DoH port 443, DoT port 853, trusted_proxies reads CF-Connecting-IP)
↓
Upstream resolvers
Key Benefits over Caddy Approach
- →No port forwarding required — tunnel is outbound only
- →No Let’s Encrypt cert management — Cloudflare handles TLS
- →Token enforcement at Cloudflare edge — invalid requests never reach the Pi
- →Works on both IPv4 and IPv6 automatically via Cloudflare anycast
- →Real client IPs preserved in AGH query log via CF-Connecting-IP header
- →No Caddy process running on the Pi
Environment
- →Raspberry Pi running Raspberry Pi OS (Bookworm or Bullseye)
- →AdGuard Home already installed and running
- →A domain managed by Cloudflare (nameservers pointing to Cloudflare)
- →Cloudflare account (free plan sufficient)
Step 1
Configure AdGuard Home
Stop AGH before editing — it overwrites changes made while running:
bash
sudo systemctl stop AdGuardHome
sudo nano /opt/AdGuardHome/AdGuardHome.yaml
Ensure the following sections are set correctly. The trusted_proxies list is Cloudflare’s published IP ranges — AGH reads CF-Connecting-IP headers only from these addresses:
AdGuardHome.yaml
tls:
enabled: true
server_name: DoHserver-hostname
force_https: false
port_https: 443
port_dns_over_tls: 853
allow_unencrypted_doh: false
http:
address: 0.0.0.0:80
dns:
trusted_proxies:
- 103.21.244.0/22
- 103.22.200.0/22
- 103.31.4.0/22
- 104.16.0.0/13
- 104.24.0.0/14
- 108.162.192.0/18
- 131.0.72.0/22
- 141.101.64.0/18
- 162.158.0.0/15
- 172.64.0.0/13
- 173.245.48.0/20
- 188.114.96.0/20
- 190.93.240.0/22
- 197.234.240.0/22
- 198.41.128.0/17
- 2400:cb00::/32
- 2606:4700::/32
- 2803:f800::/32
- 2405:b500::/32
- 2405:8100::/32
- 2a06:98c0::/29
- 2c0f:f248::/32
bash
sudo systemctl start AdGuardHome
sudo systemctl status AdGuardHome
# Verify AGH owns 443 and 853
sudo ss -tlnp | grep -E '443|853'
⚠ Cloudflare’s IP ranges change occasionally. Check https://www.cloudflare.com/ips/ periodically for updates.
Step 2
Install cloudflared
Install via the official Cloudflare apt repository for automatic updates:
bash
# Add Cloudflare GPG key and repository
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list
# Install
sudo apt update && sudo apt install cloudflared -y
# Verify
cloudflared --version
Step 3
Authenticate and Create the Tunnel
bash
# Authenticate — opens a browser URL, log in and select your zone
cloudflared tunnel login
# Create the tunnel — note the UUID printed
cloudflared tunnel create doh-tunnel
# Confirm it exists
cloudflared tunnel list
The credentials file lands in ~/.cloudflared/<UUID>.json. Copy both files to root’s home since the service runs as root:
bash
sudo mkdir -p /root/.cloudflared
sudo cp ~/.cloudflared/cert.pem /root/.cloudflared/
sudo cp ~/.cloudflared/*.json /root/.cloudflared/
Step 4
Create the Tunnel Config File
bash
sudo mkdir -p /etc/cloudflared
sudo nano /etc/cloudflared/config.yml
Paste, replacing <TUNNEL-UUID> with your actual UUID:
config.yml
tunnel: <TUNNEL-UUID>
credentials-file: /root/.cloudflared/<TUNNEL-UUID>.json
ingress:
- hostname: DoHserver-hostname
service: https://localhost:443
originRequest:
noTLSVerify: false
originServerName: DoHserver-hostname
- service: http_status:404
ℹ originServerName tells cloudflared to validate AGH’s TLS certificate against the hostname rather than localhost. The catch-all http_status:404 rejects any request that doesn’t match the hostname rule.
Step 5
Route DNS and Start the Service
Delete any existing A or AAAA record for DoHserver-hostname in the Cloudflare DNS dashboard first, then:
bash
# Creates a CNAME → <UUID>.cfargotunnel.com with orange cloud proxy
cloudflared tunnel route dns doh-tunnel DoHserver-hostname
# Install and start as systemd service
sudo cloudflared --config /etc/cloudflared/config.yml service install
sudo systemctl enable cloudflared
sudo systemctl start cloudflared
sudo systemctl status cloudflared
Step 6
Cloudflare WAF Custom Rule — Block Without Token
In the Cloudflare dashboard: Security → WAF → Custom Rules → Create rule
- →Rule name: Block DoH without token
- →Action: Block (returns 404 — indistinguishable from a missing page)
Rule expression (paste into the expression editor):
Cloudflare Rule Expression
(not http.request.uri.path contains "/your-secret-token/" and http.host eq "DoHserver-hostname:443")
⚠ Use http.host with the port number included — there is a bug in the Cloudflare UI that rejects http.hostname. Since http.host includes the port, the value must be DoHserver-hostname:443 not just DoHserver-hostname.
Step 7
Cloudflare Transform Rule — Strip Token from Path
In the Cloudflare dashboard: Rules → Transform Rules → URL Rewrite → Create rule
- →Rule name: Strip DoH token
- →When incoming requests match: URI Path — starts with —
/your-secret-token/
- →Path rewrite type: Static
- →Rewrite to:
/dns-query
This rewrites /your-secret-token/dns-query → /dns-query before forwarding to AGH via the tunnel. The WAF rule runs first, so only requests containing the token reach this rewrite.
ℹ Static rewrite is sufficient here — regex_replace requires a Business plan. Since the path is always the same the static rewrite is unambiguous.
Step 8
Test End to End
bash
# Should return a valid DNS response
dnslookup google.com https://DoHserver-hostname/your-secret-token/dns-query
# Should return 404 — no token
dnslookup google.com https://DoHserver-hostname/dns-query
# Test DoT directly (bypasses tunnel entirely)
dnslookup google.com tls://DoHserver-hostname
⚠ Do not use curl with ?dns= base64 GET requests for testing — AGH does not support this format. Use dnslookup which uses POST with binary wireformat as Apple devices do.
Optional
iOS and macOS DNS Profile
Save as dns-settings.mobileconfig and install by airdropping to iOS, opening in Safari on iOS, or double-clicking on macOS. Generate a fresh UUID with uuidgen.
dns-settings.mobileconfig
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadDisplayName</key>
<string>DNS (DoHserver-hostname)</string>
<key>PayloadIdentifier</key>
<string>com.apple.dnsSettings.managed.C54E78C7-25BD-4774-8FBD-CEA1F06F60CA</string>
<key>PayloadType</key>
<string>com.apple.dnsSettings.managed</string>
<key>PayloadUUID</key>
<string>C54E78C7-25BD-4774-8FBD-CEA1F06F60CA</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>DNSSettings</key>
<dict>
<key>DNSProtocol</key>
<string>HTTPS</string>
<key>ServerURL</key>
<string>https://DoHserver-hostname/your-secret-token/dns-query</string>
</dict>
<key>OnDemandRules</key>
<array>
<dict>
<key>Action</key>
<string>Connect</string>
</dict>
</array>
</dict>
</array>
<key>PayloadDisplayName</key>
<string>DNS Settings</string>
<key>PayloadIdentifier</key>
<string>com.example.dns.profile</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string><!-- run: uuidgen --></string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
ℹ No ServerAddresses needed — the device resolves DoHserver-hostname via normal DNS before the profile activates.
Ongoing Maintenance
Tunnel service management
bash
sudo systemctl status cloudflared
sudo systemctl restart cloudflared
sudo journalctl -u cloudflared -f
Change the secret token
Two changes required — both in the Cloudflare dashboard, nothing on the Pi:
- →WAF Custom Rule — update
not contains value to new token
- →Transform Rule — update
starts with value to new token
- →iOS/macOS profile — update
ServerURL and reinstall on devices
Update Cloudflare IP ranges in AGH
Check https://www.cloudflare.com/ips/ periodically and update trusted_proxies in AdGuardHome.yaml if ranges change. Stop AGH before editing.
Change AGH password
Generate a bcrypt hash using single quotes to prevent shell expansion of special characters:
bash
# Always use single quotes for passwords containing $, #, ! or backticks
htpasswd -bnBC 10 '' 'your-new-password' | tr -d ':\n'
# Stop AGH, edit yaml, update password field with quoted hash, restart
sudo systemctl stop AdGuardHome
sudo nano /opt/AdGuardHome/AdGuardHome.yaml
# users:
# - name: yourusername
# password: "$2y$10$yourhashhere"
sudo systemctl start AdGuardHome
Revert to Caddy (if needed)
Caddy is installed but disabled. To revert:
bash
sudo systemctl stop cloudflared
sudo systemctl disable cloudflared
sudo systemctl enable caddy
sudo systemctl start caddy