Migrating from Pi-hole to a High-Availability Technitium DNS Cluster with Local DoH & Pure Recursion
Hello fellow homelabbers,
After recently migrating my homelab DNS infrastructure from Pi-hole to a High-Availability Technitium DNS Cluster, I wanted to share my architecture, findings, and performance testing results with the community.
When designing a homelab DNS setup, you face multiple implementation choices. Because I am running my two DNS instances on significantly different generations of hardware, finding the optimal configuration required some real-world benchmarking.The Hardware & Topology.
My Proxmox environment utilizes two distinct physical hosts:Lenovo ThinkStation P520 – Running an Intel Xeon W-2135 CPU (released in 2017).Aoostar WTR Pro Max – Running an AMD Ryzen 7 8845HS CPU (released in 2024).The 2024 Ryzen CPU is substantially more powerful than the 2017 Xeon.
To optimize performance based on this hardware asymmetry, I configured my router's DHCP scope to lease the Aoostar instance (192.168.11.51) as the Primary DNS server, and the Lenovo instance (192.168.11.50) as the Secondary DNS server.
This ensures the bulk of the network load is handled by the faster CPU, while the older hardware stands by as a seamless failover node. Both servers stay perfectly synchronized using the built-in Technitium Cluster Sync application.
The Architecture: Solving the Encryption vs. Autonomy Paradox.My goal was absolute privacy inside my network, combined with complete independence from upstream public DNS providers (like Cloudflare, Google, or Quad9).
Achieving this requires balancing two distinct traffic directions:Inbound (Client to Homelab): DNS-over-HTTPS (DoH)To secure local network traffic, I deployed Nginx Proxy Manager (NPM) inside my Docker environment. NPM handles the incoming HTTPS connections on port 443 using a valid Let's Encrypt Wildcard Certificate.
To prevent complex Nginx database and SSL handshake issues (ERR_SSL_UNRECOGNIZED_NAME_ALERT), I kept NPM clean.
I created two straightforward Proxy Hosts (dns1.patad.nl and dns2.patad.nl) that route traffic on the backend directly to the Technitium LXC containers on a custom DNS-over-HTTP port (8053).To make this accessible locally without routing traffic out to the internet, I configured a Split-Horizon DNS zone within Technitium for (my domainname), pointing dns1 and dns2 directly to NPM's local IP address.
Finally, I enabled EDNS Client Subnet (ECS) and the X-Real-IP header in Technitium so the logs accurately show which local client is making requests, rather than just showing the proxy's IP.
Outbound (Homelab to Internet): Pure Recursion + DNSSEC completely disabled upstream forwarders. Technitium acts as a Recursive DNS Server, directly querying the global Root Servers, TLD servers, and Authoritative Name Servers. Because the global public DNS infrastructure does not natively support TLS for recursive queries yet, this outbound traffic utilizes standard UDP. However, to guarantee absolute security against cache poisoning and spoofing, DNSSEC Validation is strictly enforced. Technitium cryptographically validates every single response from the root down.
Benchmarking the Setup to measure the real-world performance of this implementation, I wrote a comprehensive PowerShell benchmarking script. The script flushes the local Windows DNS cache before every single request and appends a randomized prefix to the target domain (e.g., ://google.com).
This completely bypasses Technitium's local cache, forcing the cluster to perform a live, raw outbound recursion query for every single check.The test suite consists of 100 unique top-tier domains across 5 different geographical and regulatory zones: the Netherlands (.nl), Germany (.de), France (.fr), the United States (.com/.gov), and the United Kingdom (.uk).
The results were phenomenal:dns2.patad.nl (Primary / 2024 Aoostar CPU): Averaged a staggering 3 to 5 milliseconds per query. Because this server handles my primary network traffic, the global root hint structures were already warm in its RAM, allowing the Ryzen CPU to calculate recursive paths almost instantly. Furthermore, Windows leverages TLS Session Resumption, utilizing the pre-established secure HTTPS tunnel with NPM to eliminate encryption overhead.dns1.patad.nl (Secondary / 2017 Lenovo CPU): Averaged a rock-solid 11 to 13 milliseconds. The slight ~8ms delta is simply the time it takes the Windows client to spin up a fresh, separate TLS handshake with NPM for the second subdomain. Once the socket is open, processing is instantaneous.
Reliability: The test achieved 100% success with zero packet loss or timeouts, proving that the Technitium Reverse Proxy Network ACL and NPM are perfectly tuned.
The Benchmark ScriptBelow is the exact PowerShell script I used to validate my architecture. Feel free to spin it up in your own homelab to test your infrastructure!
I also included a bash script (for testing)
powershell# --- REAL SPEEDTEST DNS DOH (HTTPS) WITH LOCAL CACHE FLUSH (100 DOMAINS) ---
# --- CONFIGURATION ---
$DNSServers = @("dns2.example.com", "dns1.example.com") # Primary (fast CPU) tested first put your npm dns hosts here!
$TestRuns = 1
$ThresholdMs = 75 # Outlier threshold for raw recursive queries
# Comprehensive list of exactly 100 top domains across NL, DE, FR, US, and UK
$DomainList = @(
# --- Netherlands (.nl) - 20 domains ---
"tweakers.net", "nu.nl", "nos.nl", "buienradar.nl", "bol.com",
"rijksoverheid.nl", "rabobank.nl", "ing.nl", "kpn.com", "ziggo.nl",
"coolblue.nl", "marktplaats.nl", "belastingdienst.nl", "nlnetlabs.nl", "surf.nl",
"tudelft.nl", "uva.nl", "volkskrant.nl", "telegraaf.nl", "albertheijn.nl",
# --- Germany (.de) - 20 domains ---
"amazon.de", "spiegel.de", "bild.de", "tagesschau.de", "heise.de",
"welt.de", "zeit.de", "focus.de", "golem.de", "computerbase.de",
"ebay.de", "bahn.de", "otto.de", "zalando.de", "check24.de",
"adac.de", "telekom.com", "bundesregierung.de", "dfb.de", "kit.edu",
# --- France (.fr) - 20 domains ---
"lemonde.fr", "lefigaro.fr", "leparisien.fr", "lesechos.fr", "liberation.fr",
"allocine.fr", "cdiscount.com", "fnac.com", "vinted.fr", "carrefour.fr",
"sncf-connect.com", "impots.gouv.fr", "ameli.fr", "gouvernement.fr", "edf.fr",
"orange.fr", "free.fr", "sfr.fr", "sorbonne-universite.fr", "lequipe.fr",
# --- United States (.com / .org / .gov) - 20 domains ---
"cloudflare.com", "ietf.org", "paypal.com", "verisign.com", "google.com",
"microsoft.com", "wikipedia.org", "github.com", "amazon.com", "apple.com",
"meta.com", "netflix.com", "nasa.gov", "usa.gov", "whitehouse.gov",
"nytimes.com", "cnn.com", "mit.edu", "harvard.edu", "stanford.edu",
# --- United Kingdom (.uk) - 20 domains ---
"gov.uk", "bbc.co.uk", "nhs.uk", "ox.ac.uk", "cam.ac.uk",
"coop.co.uk", "theguardian.com", "telegraph.co.uk", "hmv.com", "ba.com",
"sky.com", "bt.com", "ee.co.uk", "vodafone.co.uk", "tesco.com",
"sainsburys.co.uk", "asda.com", "argos.co.uk", "tfl.gov.uk", "manutd.com"
)
# Allow connection using internal homelab certificates (DoH forces HTTPS)
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}
# Set output path to Desktop
$DesktopPath = [Environment]::GetFolderPath([Environment+SpecialFolder]::Desktop)
$CSVPath = $DesktopPath + "\dns_100_domains_speed_report.csv"
# --------------------
Clear-Host
Write-Host "Starting ULTIMATE 100-Domain DoH Speed Test against Cluster..." -ForegroundColor Cyan
Write-Host "Testing Netherlands, Germany, France, US, and UK via NPM.`n" -ForegroundColor Yellow
$Results = @()
foreach ($Server in $DNSServers) {
Write-Host "Server: $Server" -ForegroundColor Magenta
for ($Run = 1; $Run -le $TestRuns; $Run++) {
$Count = 1
foreach ($Domain in $DomainList) {
# Append a random prefix to force Technitium to perform a live, raw recursive query
$RandomNumber = Get-Random -Min 100000 -Max 999999
$UniqueDomain = "${RandomNumber}.${Domain}"
# Flush local Windows client DNS cache before the request
Clear-DnsClientCache
# Formulate standard RFC 8484 DoH GET URL targeting NPM endpoints
# Measure exact end-to-end round trip execution time
$Measurement = Measure-Command {
try {
$Response = Invoke-WebRequest -Uri $DohUrl -Headers @{"Accept" = "application/dns-message"} -Method Get -TimeoutSec 3 -ErrorAction SilentlyContinue
} catch {
# Capture timeouts or transport layer drops
}
}
$Latency = [Math]::Round($Measurement.TotalMilliseconds, 2)
$IsOutlier = $Latency -gt $ThresholdMs
# Tag country codes for neat terminal output formatting
$Country = "NL"
if ($Count -gt 20) { $Country = "DE" }
if ($Count -gt 40) { $Country = "FR" }
if ($Count -gt 60) { $Country = "US" }
if ($Count -gt 80) { $Country = "UK" }
if ($Latency -eq 0) {
Write-Host " [$Country] ($Count/100) TIMEOUT/ERROR - No response from $Server | $Domain" -ForegroundColor DarkRed
} elseif ($IsOutlier) {
Write-Host " [$Country] ($Count/100) SLOW RECURSION - Latency: $Latency ms | $Domain" -ForegroundColor Yellow
} else {
Write-Host " [$Country] ($Count/100) FAST REAL DoH - Latency: $Latency ms | $Domain" -ForegroundColor Green
}
$Results += [PSCustomObject]@{
Server = $Server
Country = $Country
Domain = $Domain
Latency_ms = $Latency
Outlier = if ($IsOutlier) { "Yes" } else { "No" }
}
$Count++
# Brief pause to allow network sockets to gracefully close
Start-Sleep -Milliseconds 40
}
}
}
# Export full metrics data array directly to a local CSV file
$Results | Export-Csv -Path $CSVPath -NoTypeInformation -Delimiter ","
Write-Host "`nDone! The comprehensive 100-domain speed report has been saved to your Desktop." -ForegroundColor Green
7
4 comments
Ad de Jonge
5
Migrating from Pi-hole to a High-Availability Technitium DNS Cluster with Local DoH & Pure Recursion
Home Lab Explorers
skool.com/homelabexplorers
Build, break, and master home labs and the technologies behind them! Dive into self-hosting, Docker, Kubernetes, DevOps, virtualization, and beyond.
Leaderboard (30-day)
Powered by