Technitium — Recursive DNS with Quad9 DoT
Project: Technitium DNS Server
Host: technitium / technitium.home.lab
IP: 10.69.20.53
VLAN: 20 (SERVERS)
VMID: 253
Web UI: https://dns.home.helix9.org (via Traefik) — fallback http://10.69.20.53:5380
Overview
Technitium is the recursive DNS layer for the home network. It replaces VyOS's direct upstreams with an encrypted path: queries leave the network only over DNS-over-TLS to Quad9 (malware blocking + DNSSEC). Technitium also validates DNSSEC, caches responses, and hosts the block page served when DNS blocking matches a client query.
Query flow:
client → VyOS forwarder (zone gateway :53) → Technitium (10.69.20.53:53) → Quad9 (9.9.9.9:853, DoT)
· static-host overrides · blocklists + DNSSEC
· local cache · own cache
VyOS still resolves the *.home.helix9.org / *.home.lab static mappings locally before forwarding, so Traefik split-horizon keeps working.
Infrastructure
LXC Container
| Setting | Value |
|---|---|
| Node | pve02 |
| VMID | 253 |
| IP | 10.69.20.53/24 |
| Gateway | 10.69.20.1 |
| CPU | 2 cores |
| RAM | 2048 MB |
| Swap | 512 MB |
| Disk | 8 GB (SSD-Storage) |
| Template | debian-13-standard_13.1-2 |
| Unprivileged | yes |
Ansible
Provisioned and managed via Ansible:
- Playbook:
playbooks/technitium.yml - Role:
roles/technitium/ - Host vars:
inventory/host_vars/technitium/ - Runtime: Podman Quadlet (systemd-managed container)
- Image:
docker.io/technitium/dns-server:latest - Data dir:
/srv/technitium/config(mounted as/etc/dnsinside container)
Podman Quadlet
systemctl status technitium
systemctl restart technitium
podman logs technitium
Published Ports
| Port | Protocol | Purpose |
|---|---|---|
| 53 | UDP/TCP | DNS queries (from VyOS forwarder) |
| 80 | TCP | Block Page app (HTTP) |
| 443 | TCP | Block Page app (HTTPS, self-signed) / DoH listener |
| 853 | TCP | DoT listener (available, not in active use) |
| 5380 | TCP | Web admin UI (behind Traefik) |
Upstream — Quad9 DoT
Configured in Settings → Proxy & Forwarders:
| Setting | Value |
|---|---|
| Forwarder Protocol | DNS-over-TLS |
| Forwarders | dns.quad9.net:853 (9.9.9.9) |
dns.quad9.net:853 (149.112.112.112) | |
| Concurrent Forwarding | enabled (2 concurrent) |
| DNSSEC Validation | enabled (Settings → General) |
Service = Recommended (malware blocking + DNSSEC validation).
IPv4 only — do not add the IPv6 forwarders (
2620:fe::fe,2620:fe::9). This LXC has no IPv6 route, so v6 forwarders never reach Quad9 and every query that lands on one burns a full timeout before failing over → intermittentSERVFAIL, seen on clients asgetaddrinfo ENOTFOUND(e.g. uptime-kuma flapping internal monitors Down then Up ~30s later). Keep forwarders Quad9 IPv4 only.
Other Quad9 service IPs (swap if you change policy):
| Service | IPv4 | TLS SAN |
|---|---|---|
| Recommended (malware + DNSSEC) | 9.9.9.9, 149.112.112.112 | dns.quad9.net |
| Secured w/ ECS | 9.9.9.11, 149.112.112.11 | dns11.quad9.net |
| Unsecured (no filtering/DNSSEC) | 9.9.9.10, 149.112.112.10 | dns10.quad9.net |
Forwarders are seeded by the Ansible role on first deploy via the Technitium API (/api/settings/set). Manual changes in the UI persist across redeploys — the seed task skips when current settings already match.
DNSSEC
- Validation: enabled globally (Settings → General →
Enable DNSSEC Validation). - Verification:
Expectdig @10.69.20.53 dnssec-failed.org
status: SERVFAIL+EDE: 9 (DNSKEY Missing). - Caveat: devices without a real-time clock must bypass DNSSEC for NTP hostnames. Not applicable here — all clients have working RTCs.
Traefik Integration
Web UI reachable at https://dns.home.helix9.org via the existing Traefik reverse proxy.
Dynamic config in dynamic.yaml:
http:
routers:
dns:
rule: "host(`dns.home.helix9.org`)"
entrypoints: [websecure]
service: dns
tls:
certresolver: letsencrypt
priority: 100
services:
dns:
loadBalancer:
servers:
- url: "http://10.69.20.53:5380"
Traefik terminates TLS (LE cert) and proxies plain HTTP to Technitium's web port. Technitium's own HTTPS (5381) is not enabled.
VyOS static-host-mapping added so dns.home.helix9.org resolves to Traefik (10.69.20.67) for internal clients.
DNS Blocking
Blocklists
Managed in Settings → Blocking:
| Setting | Value |
|---|---|
| Blocking Type | Custom Address |
| Custom Blocking Addresses | 10.69.20.53 |
Lists pulled on a schedule from user-configured URLs (add in the Blocking tab). Allowed-domain overrides via top-nav Allowed tab.
Block Page
The Block Page app (installed from Apps → Store) serves an HTML page when a blocked domain is queried. Config (Apps → Block Page → Config):
{
"name": "default",
"enableWebServer": true,
"webServerLocalAddresses": ["0.0.0.0", "::"],
"webServerUseSelfSignedTlsCertificate": true,
"serveBlockPageFromWebServerRoot": true,
"webServerRootPath": "wwwroot"
}
Custom HTML lives at /srv/technitium/config/apps/Block Page/wwwroot/index.html on the LXC host (volume-mounted). JavaScript reads location.hostname to display the blocked domain on the page.
HTTPS Limitation
The Block Page serves a self-signed cert. For blocked HTTPS sites:
- Regular HTTPS domains → browser shows cert warning → click-through → block page loads.
- HSTS-preloaded domains (google, stake.com, major banks, etc.) → browser refuses any cert exception → permanent error, no click-through. This is inherent to HSTS + custom-address blocking and cannot be fixed without browser-level blocking (uBlock Origin).
Query Logging
Optional — install Query Logs (Sqlite) app (Apps → Store). Writes to /etc/dns/apps/ (persists via volume). View in Technitium's Logs tab.
Firewall
All cross-zone access to Technitium is governed by the VyOS zone policy matrix (see home-router.md §9).
| From zone | To SERVERS | Reaches Technitium? |
|---|---|---|
| LOCAL (router) | ALLOW-ALL | yes — DNS forwarding chains through |
| MGMT | ALLOW-ALL | yes — full access |
| TRUSTED | ALLOW-ALL | yes — UI + block page |
| GUEST | GUEST-SERVERS | block page only (TCP 80/443 carve-out) |
| HOMELAB / IOT / DMZ | ALLOW-EST | no direct initiation — DNS via router |
Technitium's egress to Quad9 (:853) is covered by SERVERS → WAN = ALLOW-INTERNET.
Credentials
- Web UI user:
admin - Password:
vault_technitium_admin_passwordininventory/host_vars/technitium/vault.yml(Ansible Vault).
Operations
Verify full chain
# From any client in TRUSTED
dig @10.69.40.1 example.org +short # resolves normally
dig @10.69.40.1 paperless.home.lab +short # local override → 10.69.20.72
DNSSEC (run from any client — the definitive test):
dig @10.69.40.1 dnssec-failed.org # MUST be status: SERVFAIL → validation enforced
dig @10.69.40.1 +dnssec cloudflare.com # flags line shows ad → answer authenticated
A bogus domain returning SERVFAIL proves validation (a non-validating resolver would hand back the bad A record). The ad flag reaching the client depends on the router doing set service dns forwarding dnssec validate — without it the VyOS forwarder strips ad, though Technitium still validates (the SERVFAIL still propagates).
DoT — Technitium runs as a rootful podman container on a bridge netns, so tcpdump/ss on the LXC host see nothing (no CAP_NET_RAW, separate netns). Look inside the container's network namespace:
# on technitium LXC
PID=$(sudo podman inspect technitium --format '{{.State.Pid}}')
sudo nsenter -t $PID -n ss -tn | grep :853
# → ESTABLISHED 10.88.0.6:xxxxx → 9.9.9.9:853 / 149.112.112.112:853 = DoT active
Gotcha: don't test cache-misses with random subdomains of DNSSEC-signed zones (e.g.
*.example.com) — Technitium answers them from aggressive NSEC cache without any upstream query, so you'll wrongly see no:853. Use distinct real apex domains, or read the Dashboard query log. The Web UI (Settings → Proxy & Forwarders) also shows the live protocol (DNS-over-TLS) and forwarders directly.
Flush cache
UI → Cache tab → Flush Cache button. Or API:
curl -sk "http://10.69.20.53:5380/api/user/login?user=admin&pass=$PASS" | jq -r .token | \
xargs -I{} curl -sk "http://10.69.20.53:5380/api/cache/flush?token={}"
Switch Quad9 service / forwarders
Edit technitium_forwarders in roles/technitium/defaults/main.yml (or override in host_vars/technitium/vars.yml), then:
ansible-playbook playbooks/technitium.yml --tags seed
Rebuild container
ansible-playbook playbooks/technitium.yml
Data in /srv/technitium/config survives container replacement.
Known Issues / Caveats
Short-name resolution
The quadlet uses docker.io/technitium/dns-server:latest (fully qualified). Podman's strict short-name mode on Debian 13 rejects unqualified image names — don't drop the registry prefix.
HSTS block page
HSTS-preloaded domains (e.g. stake.com, google.com) cannot show the block page — browser refuses the self-signed cert without a click-through option. Outcome is "can't connect" rather than a pretty page. Accept as the block signal or switch blocking type to NX Domain for a cleaner fail UX at the cost of losing the page on HTTP/non-preloaded sites.
Bootstrap chicken-and-egg
If Technitium itself is down, VyOS forwarder has no upstream and internal DNS fails. Router's system name-server (1.1.1.1, 9.9.9.9) only serves router-local resolution (apt/NTP), not client queries. Mitigation: for extended outages, temporarily revert VyOS upstream:
configure
set service dns forwarding name-server 1.1.1.1
commit
Swap back once Technitium is healthy.