Constantly tweaking and adjusting, making things better and more Secure
for the Dutch among us: Nederlandse site https://homelab.patad.nl/dns/advanced-technitium/
# Technitium DNS: My Advanced Setup & High Availability
What I changed:
  • Point 5 ( Critical Edge Cases & Troubleshooting )
and the DoH script
In this document(.md file), I will describe the advanced architecture of my Technitium DNS cluster. I am documenting how I configured the Access Control Lists (ACL) for my VLANs, how my active clustering works, how I achieve High Availability via `keepalived`, and how I integrate everything with my Nginx Proxy Manager (NPM).
---
## 1. Network Access Control List (ACL) & Recursion
By default, Technitium refuses DNS resolution (recursion) for IP addresses outside its own local subnet. To ensure that all my local VLANs and WireGuard clients can resolve domain names, I explicitly added these subnets to the ACL.
### My Configuration (Example Networks):
In my Technitium dashboard, I navigated to **Settings** > **Recursion**, located the **Network Access Control List (ACL)**, and added my networks.
*Below is the overview of the subnets I use for my VLAN structure:*
* `192.168.11.0/24` (My Management / Main Network)
* `192.168.21.0/24` (My IoT / VLAN 21)
* `192.168.31.0/24` (My Gadgets / VLAN 31)
* `192.168.41.0/24` (My Guests / VLAN 41)
* `10.6.0.0/24` (My WireGuard VPN Subnet)
> **Important for my setup:** If I forget to configure this, clients on my VPN subnet or other VLANs will notice they have an active network connection, but they won't be able to load any websites or local domains because the DNS queries will simply be dropped.
---
## 2. Technitium Clustering & Node Distribution
To guarantee absolute redundancy against the failure of an entire hypervisor, I physically separated my two Technitium DNS servers across my Proxmox environments:
* **DNS 1 (Primary Node - `192.168.11.50`):** Runs as an LXC container within my main cluster (**PVE1** / **PVE2**).
* **DNS 2 (Secondary Node - `192.168.11.51`):** Runs as an LXC container on my standalone **PVE3** node.
### How I configured the synchronization:
* On my **Secondary Node** on PVE3, I configured my main server (`192.168.11.50`) as a cluster partner.
* I generated an API key under *Settings > API Keys* so the servers can securely communicate over HTTPS.
* Whenever I apply a new local DNS zone or a blocklist change on my Primary node, Technitium automatically pushes it over the network to the standalone PVE3 node within seconds.
---
## 3. High Availability with keepalived (Virtual IP & Priority)
Even though I run two physically separated DNS servers, I didn't want to manually specify two separate DNS IP addresses in my router's DHCP options or on my Proxmox hosts.
To solve this, I deployed **keepalived**. This service runs **actively on both DNS containers** to collectively manage a single **Virtual IP (VIP)** at `192.168.11.53`.
### My Priority Configuration (VRRP Logic):
To ensure that DNS 1 (on the main cluster) always takes priority as long as it is online, I use a specific priority distribution (`priority`) combined with a health check script.
#### Step 1: Configuring the Primary DNS (192.168.11.50)
My `keepalived.conf` on the main server looks exactly like this:
```ini
global_defs {
enable_script_security
script_user root
vrrp_garp_master_delay 1
vrrp_garp_master_repeat 3
}
vrrp_script check_technitium {
script "/usr/bin/pgrep dotnet"
interval 2
weight -20
}
vrrp_instance VI_1 {
state BACKUP
interface eth0
virtual_router_id 53
priority 150 # The Primary gets the highest base priority
nopreempt
authentication {
auth_type PASS
auth_pass homelabDNS
}
virtual_ipaddress {
192.168.11.53/24
}
track_script {
check_technitium
}
}
```
#### Step 2: Configuring the Secondary DNS (192.168.11.51)
Because the secondary container acts as the failover node, I set the priority lower (to 140). If Technitium on the Primary server crashes, the script deducts 20 points from its priority (`150 - 20 = 130`). Since 130 is lower than the 140 on the Secondary node, the Secondary immediately takes over the VIP!
My `/etc/keepalived/keepalived.conf` on the `.51` server:
```ini
global_defs {
enable_script_security
script_user root
vrrp_garp_master_delay 1
vrrp_garp_master_repeat 3
}
vrrp_script check_technitium {
script "/usr/bin/pgrep dotnet"
interval 2
weight -20
}
vrrp_instance VI_1 {
state BACKUP
interface eth0
virtual_router_id 53 # Must match exactly (53)
priority 140 # Lower than the Primary (150)
nopreempt
authentication {
auth_type PASS
auth_pass homelabDNS # Must match exactly
}
virtual_ipaddress {
192.168.11.53/24
}
track_script {
check_technitium
}
}
```
### Why this is so powerful:
Thanks to `keepalived`, I only need to configure **one single DNS server IP (`192.168.11.53`)** in my Asus router's DHCP settings and in the `/etc/resolv.conf` of all my Proxmox hosts (**PVE1**, **PVE2**, and **PVE3**).
```text
# My Proxmox /etc/resolv.conf Setup on all nodes:
nameserver 192.168.11.53
```
If the PVE node hosting DNS 1 reboots, DNS 2 on PVE3 notices the missing heartbeat. It immediately changes its state from standby to active and claims the VIP (`.53`) in a fraction of a second. Once DNS 1 boots back up, its higher priority (`150`) ensures it gracefully takes back the VIP.
---
## 4. Setting Up Zones & Nginx Proxy Manager (NPM) Integration
To access my local services via clean URLs and support encrypted DNS traffic (DNS-over-HTTPS / DoH) through Firefox (Max Protection), I set up a **Primary Zone** inside Technitium for my domain `example.com`.
### Step 1: Creating the Zone
1. I went to **Zones** > **Add Zone**.
2. I created a zone for my domain: `example.com`.
### Step 2: Pointing Records to NPM
Instead of assigning a separate local IP address to every single application in my DNS zone, I pointed **all A-records to the IP address of my Nginx Proxy Manager (NPM)**. My NPM then handles the incoming traffic based on the subdomain string (HTTP header) and proxies it to the correct LXC or VM.
My NPM runs as a Docker container in **host mode** on a specific Proxmox VM with the local IP address **`192.168.11.139`**.
I created the records (or a wildcard `*.example.com`) and pointed them to the proxy:
* **Name:** `proxy.example.com` (and other required hosts)
* **Type:** `A`
* **TTL:** `3600`
* **IP Address:** `192.168.11.139`
This setup routes every subdomain request (including my browser's DoH queries) directly to my proxy on ports 80/443. NPM utilizes a wildcard Let's Encrypt certificate (`*.example.com`) to handle full SSL/TLS encryption, then forwards the traffic internally to the web/DoH port (`8053`) hosted on the Technitium VIP (`192.168.11.53`).
---
## 5. Critical Edge Cases & Troubleshooting
### 1. The Proxy "Chicken-and-Egg" Problem (DNS Loopback)
Because NPM proxies DoH traffic back to the Technitium VIP (`192.168.11.53`), the underlying VM hosting NPM *must never* depend on that same Technitium cluster for its own internet connection. If the DNS cluster goes offline, the NPM VM would lose name resolution, completely breaking NPM's ability to renew SSL certificates or contact external APIs (like Cloudflare).
**My Solution:**
I explicitly configured the network settings on the NPM Docker VM (`/etc/netplan/50-cloud-init.yaml`) to completely bypass the Technitium cluster and always use public upstream DNS servers:
```yaml
nameservers:
addresses:
- 1.1.1.1 # Cloudflare DNS (where my public example.com zone lives)
- 9.9.9.9 # Quad9 DNS Fallback
```
This decouples the proxy layer from internal DNS availability, ensuring maximum reliability.
### 2. What if the entire internal cluster goes down?
While a simultaneous failure of both Technitium nodes is highly unlikely due to the cluster separation, a massive power failure could theoretically take down the entire internal network layer.
**My Disaster Recovery Plan:**
If the entire internal DNS cluster falls offline, I will manually restore internet access for my home clients through the router:
1. Log into the Asus router administration panel.
2. Navigate to the **DHCP / LAN DNS** configuration settings.
3. Temporarily overwrite the local VIP (`192.168.11.53`) with the **DNS IP address of my Internet Service Provider (ISP)** or a public resolver (`1.1.1.1`).
4. Once the Technitium cluster is recovered and stable, I switch the router's DHCP DNS back to the VIP.
---
## 6. Performance Benchmarking & Automated Excel Reporting
To stress-test the stability and latency of my DNS-over-HTTPS (DoH) routing from the outside world, I wrote a custom PowerShell script. It fires 120 unique live recursion requests across 6 global regions, utilizes cache-busting to force live lookups, and automatically generates an elegant Excel spreadsheet complete with a clustered column chart.
### Excel Compatibility Note
!!! note "Excel Language Optimization"
This script utilizes the `New-ExcelChartDefinition` engine within the `ImportExcel` module. Since the module writes XML chart references natively in English, this specific layout maps flawlessly to the standard configuration of an English Excel installation. The raw dataset of all 120 metrics is written to the `"Raw Data"` sheet, while the computed overview table and its matching clustered column chart are generated cleanly on a dedicated sheet named `"Chart Summary"`.
### The PowerShell Benchmark Script (`doh-test.ps1`)
To run this script directly via `./doh-test.ps1` in your terminal, make sure to adjust your execution policy once via an Administrator terminal window first:
`Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force`
```powershell
# ==============================================================================
# TECHNITIUM DNS-OVER-HTTPS (DoH) STRESSTEST & EXCEL BENCHMARK
# ==============================================================================
# --- CLEANUP ---
\$Global:Results = @()
# --- CONFIGURATION ---
\$DohDomain = "://example.com"
\(ThresholdMs = 75\)DelayMs = 250
# Target path on your Desktop
ExcelPath = "([Environment]::GetFolderPath('Desktop'))\DoH_Benchmark_Results.xlsx"
\$DomainList = @(
# --- Netherlands (.nl) - 20 domains ---
# --- Germany (.de) - 20 domains ---
# --- France (.fr) - 20 domains ---
# --- United States (.com / .org / .gov) - 20 domains ---
# --- United Kingdom (.uk) - 20 domains ---
# --- Asia (.cn / .jp / .in / .kr / .sg) - 20 domains ---
)
Clear-Host
Write-Host "==========================================================================" -ForegroundColor Cyan
Write-Host " Starting Windows-Curl DoH Speed Test & HA Failover Benchmark" -ForegroundColor Cyan
Write-Host "==========================================================================" -ForegroundColor Cyan
\$Index = 0
foreach (Domain in DomainList) {
# The break statements guarantee pristine region mapping
Country = switch (Index) {
{ \$_ -lt 20 } { "NL"; break }
{ \$_ -lt 40 } { "DE"; break }
{ \$_ -lt 60 } { "FR"; break }
{ \$_ -lt 80 } { "US"; break }
{ \$_ -lt 100 } { "UK"; break }
Default { "AS"; break }
}
# Cache-busting: Generates unique subdomains forcing live lookup at the root
\(RandomNumber = Get-Random -Minimum 100000 -Maximum 900000\)UniqueDomain = "RandomNumber.{Domain}"
\$TargetUrl = "BaseUrl?name={UniqueDomain}&type=A"
\(Latency = 0\)Status = "TIMEOUT/ERROR"
\$Color = "Red"
try {
\$CurlResponse = & curl.exe -k -s -X GET -H "Accept: application/dns-json" -w "%{time_total}" "TargetUrl" -o "env:TEMP\doh_null.txt"
if (null -ne CurlResponse) {
CleanResponse = CurlResponse.ToString().Trim() -replace ',', '.'
if (\$CleanResponse -match '^[0-9.]+\(') {\)RawTime = [double]\(CleanResponse\)Latency = [Math]::Round(\(RawTime * 1000, 2) } } } catch {\)Latency = 0 }
if (\(Latency -gt 0) {\)Status = "FAST REAL DoH"
\$Color = "Green"
if (Latency -gt ThresholdMs) { Status = "SLOW RECURSION"; Color = "Yellow" }
}
DisplayCount = (Index + 1).ToString().PadLeft(3)
Write-Host " [\(Country] (\)DisplayCount/120) Status - Latency: Latency ms | Target: Domain" -ForegroundColor Color
# Save metadata array for the Excel pipeline
\$Global:Results += [PSCustomObject]@{
Number = \$Index + 1
Region = \$Country
Domain = \$Domain
Latency_ms = \$Latency
Status = \$Status
Timestamp = (Get-Date -Format "HH:mm:ss")
}
\$Index++
Start-Sleep -Milliseconds \$DelayMs
}
# --- AUTOMATIC EXCEL & CHART EXPORT ---
Write-Host "`n[Excel] Processing results and generating chart..." -ForegroundColor Cyan
if (Get-Module -ListAvailable -Name ImportExcel) {
if (Test-Path $ExcelPath) { Remove-Item $ExcelPath -Force }
# 1. Export raw telemetry dataset into sheet 1
$Global:Results | Export-Excel -Path $ExcelPath -WorksheetName "Raw Data" -AutoSize -FreezeTopRow
# 2. Compute regional latency averages
$RegionSummary = @()
$Regions = @("NL", "DE", "FR", "US", "UK", "AS")
foreach ($R in $Regions) {
$ValidQueries = $Global:Results | Where-Object { $_.Region -eq $R -and $_.Latency_ms -gt 0 }
if ($ValidQueries) {
$Avg = [Math]::Round(($ValidQueries | Measure-Object Latency_ms -Average).Average, 2)
$RegionSummary += [PSCustomObject]@{
Region = $R
Latency_ms = $Avg
}
}
}
# 3. Create independent Chart Definition (Optimized for English systems)
$ChartConfig = New-ExcelChartDefinition -XRange "Region" -YRange "Latency_ms" -ChartType ColumnClustered -Title "Average DoH Latency per Region (example.com)" -NoLegend
# 4. Write analytical summary to sheet 2 and embed the column chart side-by-side
$RegionSummary | Export-Excel -Path $ExcelPath `
-WorksheetName "Chart Summary" `
-AutoSize `
-ExcelChartDefinition \$ChartConfig
Write-Host "[Excel] Success! Your analytical dashboard is ready on your Desktop:" -ForegroundColor Green
Write-Host "-> \$ExcelPath" -ForegroundColor Yellow
} else {
Write-Host "[Excel] Error: The 'ImportExcel' module is not installed." -ForegroundColor Red
}
```
For the Dutch people:
Heb je een nederlandstalige excel draaien gebruik dan het script wat je hieronder in de bijlage vindt
je kunt deze rustig bekijken en als je deze wilt draaien vanzelf spreken ...txt veranderen naar ...ps1
0
1 comment
Spruitmans De spruit
5
Constantly tweaking and adjusting, making things better and more Secure
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