What problem are we solving?

Unless you’ve changed it, every device on your network gets its DNS from your ISP, usually by way of your router, and that has three problems:

  • The lookups travel in plaintext. Anyone on the path between your network and the resolver, your ISP included, can read every name your devices look up, and can change the answers on the way back.
  • The answers often go unchecked. Many resolvers don’t validate DNSSEC, so a forged answer looks like any other, and your devices take it.
  • You can’t see any of it, while whoever runs the resolver sees all of it. You don’t know which device asks the most, or which phone keeps retrying a domain that went dark last week, but the resolver’s owner knows every name your devices look up and can change what you get back.

This guide replaces that resolver with Technitium DNS Server on a Debian 13 host. Lookups leave your network encrypted, signed answers are checked before your devices get them, and the dashboard shows what your network asks for most.

Coming from Pi-hole or AdGuard Home? Those are blockers that pass lookups to a resolver. Technitium is a resolver that can also block, so this series sets up resolving first and adds blocking in part 4.

Technitium’s defaults already answer only private networks and validate DNSSEC. Three things are left to you: the console starts on plain HTTP with the password admin, lookups go out in plaintext until you set encrypted upstreams, and Debian doesn’t firewall the host. This guide handles all three before any client depends on the server.

At the end of this part: a Technitium DNS server that

  • answers only your own networks
  • forwards over TLS to Cloudflare or Quad9, one at a time, with failover
  • validates DNSSEC and says why an answer failed
  • rate-limits any one client
  • keeps its console behind SSH until it has a certificate
  • accepts nothing from the network except SSH, DNS, ICMP and the console’s HTTPS port


Before you start

Time
45 minutes
Level
Intermediate

You’ll need

The resolver host

Debian 13, fresh, nothing else on itThe firewall step replaces its whole ruleset.
A static address, and internet accessClients point at it; the installer downloads Technitium and .NET.
An SSH server, and rootTick "SSH server" when you install Debian.

A second machine

For the DNSSEC testsFor SSH and the console
WindowsPowerShell, or dig in WSLThe built-in ssh, in PowerShell
macOSdig from brew install bindThe built-in ssh
Linuxdig from bind9-dnsutilsThe built-in ssh

For the console certificate step only: a certificate for the resolver's name. Part 2 makes one.


Check the host before you install

Everything below runs as root: log in as root, or start with sudo -i if the host has sudo (a Debian install given a root password doesn’t).

The addresses in this guide are placeholders: 192.0.2.0/24 is a range RFC 5737 reserves for documentation. It belongs to no one and reaches nothing, so a command that still has it in does nothing useful, and the firewall file in the next step would lock you out. Where you see 192.0.2.53, put your resolver’s address; where you see 192.0.2.0/24, your LAN. resolver.example.com stands for the name part 2 gives your resolver. The dig tests query the real example.com on purpose; leave those as they are.

Confirm the host has what “Before you start” asks for:

bash
ip -br addr
systemctl is-active ssh

The first shows the address your clients will use; the second should print active. Internet access shows itself in the next step: if apt update prints Err: lines, fix that before going on, because apt reports them and carries on.

Then see whether anything on the host already listens on the DNS port:

bash
ss -ulpn 'sport = :53'
ss -tlpn 'sport = :53'

An empty result is the normal case on Debian: it does not install systemd-resolved unless you ask for it. If systemd-resolved does show up, it owns a stub listener on 127.0.0.53 that would collide with Technitium. You don’t need to remove it by hand; the installer stops and disables it, and writes a resolv.conf that points the host at its own new resolver on the loopback address. Knowing that is coming is the point of looking now.

Anything else on port 53, such as dnsmasq or Unbound, is yours to stop or reconfigure before you go on; the installer only handles systemd-resolved.

Check the clock while you are here. DNSSEC validation compares signature dates against the system time, and a host whose clock is wrong will reject every signed answer:

bash
timedatectl

System clock synchronized: yes is the line to look for.

Why the clock matters: Technitium’s own settings page warns about this for devices without a real-time clock. A virtual machine gets its time from the hypervisor and is normally fine; a Raspberry Pi that boots with 1970 on the clock will fail to validate the NTP server’s name and never catch up. The fix in that case is a conditional forwarder zone for the NTP server’s domain with validation off, which the warning spells out.

Close everything else with nftables

Do this before the install, so the console never faces your network with its default password. Three things should be reachable from the LAN and nothing else: SSH, DNS on 53, and the console’s HTTPS port 53443, which has nothing behind it until the certificate step turns it on. The console’s plain-HTTP port, 5380, stays closed to the network; you reach it through SSH.

nftables is Debian’s firewall, but a minimal image may not have the package and the service is not enabled until you enable it:

bash
apt update
apt install -y nftables

This file is the host’s whole firewall: flush ruleset clears every table, Docker’s included, so it suits a host that only runs the resolver. On a host with other rules, add these lines to the firewall it already has instead. Set LAN at the top to your own network before you load it; for several subnets, list them in braces, as in { 192.0.2.0/24, 198.51.100.0/24 }. Keep your SSH session open while you load it:

/etc/nftables.conf
#!/usr/sbin/nft -f
define LAN = 192.0.2.0/24
flush ruleset

table inet filter {
  chain input {
    type filter hook input priority filter; policy drop;
    iif lo accept
    ct state established,related accept
    ip protocol icmp accept
    meta l4proto ipv6-icmp accept
    ip saddr $LAN tcp dport 22 accept
    ip saddr $LAN udp dport 53 accept
    ip saddr $LAN tcp dport 53 accept
    ip saddr $LAN tcp dport 53443 accept
  }
  chain forward {
    type filter hook forward priority filter; policy drop;
  }
  chain output {
    type filter hook output priority filter; policy accept;
  }
}

Check the syntax without loading it, then load it and make it permanent:

bash
nft -c -f /etc/nftables.conf
nft -f /etc/nftables.conf
systemctl enable --now nftables

Before you close your session, open a second SSH connection from the second machine. The open one survives a wrong rule, because established traffic is always let through; only a new connection proves port 22 is allowed. A connection to a port you didn’t allow hangs rather than refusing; that’s the drop policy.

If the new connection can’t get in, run nft flush ruleset in the session that’s still open. It removes every rule, so you can fix LAN in the file and load it again; fix it before you log out, because systemctl enable has already set the file to load at every boot.

The rules above allow an IPv4 LAN only; if your clients also reach the resolver over IPv6, add a define LAN6 with that prefix and matching ip6 saddr $LAN6 lines, or they lose DNS and SSH the moment the policy loads. Part 3 adds the encrypted listeners on 853 and 443 to this file when it turns them on.

Install Technitium

The project ships an install script for Linux that fetches the ASP.NET Core runtime it needs, creates a dns-server system user, and sets up a hardened systemd service. This is the command from Technitium’s own install page, minus its sudo, since you are already root; a minimal Debian install may not have curl, so it comes first:

bash
apt install -y curl
curl -sSL https://download.technitium.com/dns/install.sh | bash

It installs to /opt/technitium/dns, keeps configuration under /etc/dns, logs to /var/log/technitium/dns, and starts a service called dns running as the dns-server user, with the filesystem read-only to it apart from those two directories. It also points the host’s own /etc/resolv.conf at the loopback address, so from here on the server resolves through itself. Confirm it is up and listening:

bash
systemctl status dns --no-pager
ss -ulpn 'sport = :53'

The same script is also the upgrade path: run it again later and it updates in place, along with the .NET runtime it put in /opt/dotnet. apt upgrade will never touch either of them, which matters in part 6. The changelog is where to look before any re-run.

Before you pipe it to bash: piping a script from the internet into a root shell is a habit worth breaking even for software you trust. Reading it also tells you what it changes on the host, which is more than you might expect: it stops systemd-resolved if present, rewrites resolv.conf, sets dns=none in NetworkManager’s config if NetworkManager is installed, installs the libicu package with apt, and fetches Microsoft’s .NET installer the same piped way. If that last part bothers you, install aspnetcore-runtime-10.0 from Microsoft’s apt repository first; the script looks for exactly that version, uses it if it finds it, and apt then keeps it patched.

It also always installs the latest release. That is what you want on day one; 15.5.1, 15.6 and 15.6.2 each shipped security fixes. Read the release notes before re-running it on a server that is already working.

Change the admin password and make a separate API user

Open the console over SSH

Port 5380 stays closed to the network, so reach the console through an SSH tunnel from the second machine:

bash
ssh -N -o ExitOnForwardFailure=yes -L '[::1]:5380:[::1]:5380' you@192.0.2.53

It forwards port 5380 on your machine, through SSH, to the console on the resolver, so your browser reaches the console without the console ever being open to the network. Then browse to http://[::1]:5380. On Windows, run the tunnel in PowerShell; Command Prompt passes the quotes through to ssh.

If the page won’t load and the tunnel prints connect failed, the resolver has IPv6 turned off; forward to its LAN address instead:

bash
ssh -N -o ExitOnForwardFailure=yes -L '[::1]:5380:192.0.2.53:5380' you@192.0.2.53

Change the admin password

With the default admin / admin still in place, the console logs you straight in and shows a change-password dialog. The dialog can be dismissed, so don’t: set a long password from your password manager now. This account can change every setting on the server, so from here on use it for settings only.

Make a user for scripts

Anything that will talk to the API from a script gets its own user and token, never the admin password:

  1. Administration › Groups: Add Group, named for the job, such as dns-automation.
  2. Administration › Users: Add User with its own long password, then open the user and add the group under Member Of.
  3. Administration › Sessions: Create Token for that user with a Token Name, and put the token in your password manager. The console shows it in full only once.

The group gets permissions when a later part has a script that needs them, through the zone’s Permissions. Until then the user can only view, through the built-in Everyone group that every user belongs to. If a script leaks the token, you revoke that one token. Scripts on other machines reach the API once the console has HTTPS, in the certificate step.

Why a second user: the admin account can reconfigure recursion, forwarders and blocking. A token that only edits records can’t. Scoping it now costs two minutes; scoping it after the token has been in a workflow export for a year costs a rotation and an audit.

Answer only your own networks with the Recursion ACL

Settings › Recursion

The first setting on this tab decides who gets answers for names the server doesn’t host itself. Choose Allow Recursion Only For Private Networks, the default, and save; if your clients use IPv6 from your ISP, read the next part first.

Option Who gets answers
Deny Recursion No one; for a server that only hosts its own zones
Allow Recursion Anyone who can reach the server; if this is selected, change it
Allow Recursion Only For Private Networks Private IPv4 (RFC 1918, loopback, link-local and 100.64.0.0/10, which Tailscale uses) and IPv6 outside 2000::/3
Use Specified Network Access Control List Only the networks you list

If your clients use IPv6 from your ISP

The private-networks option treats every address in 2000::/3, the global range your ISP hands out, as public, so a client that reaches the resolver from one of those addresses is refused. ULA addresses (fd00::/8, which Tailscale also uses) and link-local ones are fine. If your clients use your ISP’s prefix, choose Use Specified Network Access Control List and list your networks, one per line, adding any others you use, such as 100.64.0.0/10 for Tailscale:

text
192.0.2.0/24
2001:db8:1234::/48

2001:db8::/32 is IPv6’s documentation range, like 192.0.2.0/24; put your own prefix there. Entries are checked from the top and the first match wins, a ! in front of one denies it, and anything that matches nothing is refused, apart from the server’s own loopback.

What the ACL doesn’t cover

It only gates recursion. Zones the server hosts itself are answered before this check, under each zone’s own Query Access setting, which part 2 sets when it creates your zone. Until then there is nothing to gate, and the firewall from step 2 already keeps port 53 to your LAN.

Check QNAME minimisation

On the same tab, QNAME Minimization (RFC 9156) should be ticked; it is by default. When Technitium recurses on its own, it means each server on the way (the root, .com, then example.com) sees only the part of a name it’s responsible for, not the whole mail.corp.example.com.

With the TLS forwarders you set two steps on, it has nothing to do, since forwarding sends every query whole; it starts to matter if you ever empty the Forwarders box. Leave it on: it only adds a few queries while the cache is cold.

Rate-limit misbehaving clients with QPM limits

Settings › General

Find the Queries Per Minute (QPM) limits. Each row is a prefix length and a limit: a /32 row limits every single address on its own, a /24 row makes a whole subnet share one budget. Every row a client falls under is checked, and the client is limited the moment any one of them is exceeded, averaged over the sample window.

A fresh install ships two IPv4 rows, and for a home network they are about right:

Prefix UDP limit TCP limit Catches
32 600 600 One device in a loop
24 6000 6000 A whole subnet going wrong

If the /32 row is missing on your install, add it back; it is the one that catches a single misbehaving device without touching anyone else. Leave QPM Sample Size at 5 minutes.

Leave QPM Limit UDP Truncation at its default of 50 (and put it back if an earlier install set it to 0): when a client goes over the limit, half its UDP queries get a truncated reply that tells a real client to retry over TCP, while a spoofed flood can’t. At 0 everything over the limit is silently dropped, which makes a misconfigured device look like a dead network.

One host that looks like a hundred: a Docker host NATs every container’s lookups through its own address, and a busy one can exceed 600 a minute on its own. Put hosts like that in the QPM Limit Bypass List rather than loosening the /32 row for everyone.

Forward over TLS to two independent providers

Settings › Proxy & Forwarders

The Forwarders box takes one upstream per line in the form name (address:port); the name is what the TLS certificate is checked against. Type these two lines in:

text
cloudflare-dns.com (1.1.1.1:853)
dns10.quad9.net (9.9.9.10:853)

Quad9 runs several services on different addresses, and the one to use isn’t the one Technitium lists:

Address TLS name Blocks malicious domains In Quick Select
9.9.9.9 dns.quad9.net Yes Yes, as “Quad9 Secure”
9.9.9.10 dns10.quad9.net No No; type it in

Both validate DNSSEC (9.9.9.10 since June 2026), and Cloudflare’s 1.1.1.1 doesn’t block either. Mixed with an unfiltered Cloudflare, 9.9.9.9’s blocking would depend on which forwarder answered, and its false positives would look like names failing at random. Blocking is part 4’s job, on your own server, where you can see it.

Choose DNS-over-TLS as the forwarder protocol. Leave Enable Concurrent Forwarding checked, and set Forwarder Concurrency to 1.

That last number is the one most people get wrong:

Set to Each query goes to Who sees your lookups
2 Both forwarders at once; the faster answer wins Both providers
1 The fastest; the next if the first times out or fails One provider at a time

Concurrency 2 feels like redundancy, but it hands your complete query history to two companies instead of one, and makes each answer a coin flip between their policies. At 1, Technitium orders the forwarders by measured latency and keeps the same failover. The one deliberate exception: about one query in twenty goes to a forwarder picked at random, so that the latency figures for the one you’re not using stay current and the failover is to a server known to be alive.

Which two, or none: Cloudflare’s 1.1.1.1 and Quad9’s 9.9.9.10 are run by different organisations on different continents, and both offer DNS-over-TLS.

Or leave the box empty, and Technitium recurses from the root servers itself. No public resolver gets your whole query stream; in exchange, the traffic to the root, TLD and authoritative servers is plaintext, so your ISP can read it, and cold-cache lookups are slower. Encrypted forwarders are the better default for a first resolver; recursion is the one to try once part 5 has given you a cache to measure.

Validate DNSSEC, and prove it

Settings › General

Enable DNSSEC Validation is on by default in current releases. Confirm it is ticked rather than assume it; an older install, or one restored from a backup, may differ.

Technitium validates for itself whether or not the upstream does, which turns “encrypted to a forwarder I trust” into “encrypted, and I’d notice if an answer were forged.”

What it doesn’t cover: validation only works for domains that are signed. For the rest, still most of them, it can’t tell a forged answer from a real one; the encrypted hop to the forwarder is what protects those.

Now prove it from your second machine with three queries, each showing something different:

  1. dnssec-failed.org is published with a broken signature on purpose, so it must fail.
  2. The same name with checking disabled must resolve, which shows the failure came from validation and not from the network.
  3. example.com is signed correctly, so it must come back validated. This is the one that tells you validation is on.

Windows

Windows has no dig. PowerShell’s Resolve-DnsName runs all three queries:

powershell
Resolve-DnsName dnssec-failed.org -Server 192.0.2.53
Resolve-DnsName dnssec-failed.org -Server 192.0.2.53 -DnssecCd
Resolve-DnsName example.com -Server 192.0.2.53 -DnssecOk

What you should see:

  • The first fails with a DNS server failure.
  • The second returns an address.
  • The third lists an RRSIG record among the A records.

That RRSIG stands in for the ad flag, which PowerShell doesn’t print: Technitium only asks its forwarders for signatures while it is validating, so with validation off there is none to show.

The gap: PowerShell shows the first test’s failure but not who caused it. Cloudflare and Quad9 reject the broken domain too, so it can’t tell Technitium’s validator from theirs. The third test still proves validation is on; to see Technitium’s own Attack detected! text, use the Linux version below in WSL, where sudo apt install bind9-dnsutils gets you dig, or run it on the resolver itself over SSH after apt install -y bind9-dnsutils.

Linux and macOS

On a Mac, run dig -v first. The dig that comes with macOS reports 9.10 and is too old to print the error codes this step reads, so use the one from brew install bind.

The broken domain first:

bash
dig @192.0.2.53 dnssec-failed.org

The status must be SERVFAIL, and the OPT PSEUDOSECTION says why, in Extended DNS Error codes such as EDE: 9 (DNSKEY Missing) or EDE: 7 (Signature Expired), depending on how that zone is broken on the day.

Look for one whose text starts Attack detected!: that is Technitium’s own validator. Cloudflare and Quad9 reject this domain too, so a SERVFAIL alone could be theirs, and when it is, the EDE names the forwarder instead.

The same name with checking disabled:

bash
dig @192.0.2.53 dnssec-failed.org +cd

Now it answers with an address: the domain resolves, so the SERVFAIL came from validation, not from a network fault.

Then a domain that is signed correctly:

bash
dig @192.0.2.53 example.com +dnssec

The flags line shows ad, the resolver’s assertion that it validated the chain itself, and an RRSIG record sits next to the A records. With validation off, both are missing, while dnssec-failed.org still fails at the forwarders.

Point one client at it and watch

Set one machine’s DNS to the resolver by hand before you change DHCP for everyone; the second machine is the obvious one. If its DNS was ever set by hand, note those servers first: the undo commands below return it to automatic DNS, not to what was there before.

Windows

Use PowerShell run as administrator. Get-DnsClientServerAddress lists your interfaces; replace Ethernet if yours has another name:

powershell
Set-DnsClientServerAddress -InterfaceAlias 'Ethernet' -ServerAddresses 192.0.2.53

Run Get-DnsClientServerAddress again and 192.0.2.53 should be the interface’s IPv4 server. Set-DnsClientServerAddress -InterfaceAlias 'Ethernet' -ResetServerAddresses returns it to automatic DNS.

Linux

On a stock Debian client it is one line in /etc/resolv.conf; NetworkManager or DHCP may rewrite the file later, which is fine for a test:

/etc/resolv.conf
nameserver 192.0.2.53

On a machine that runs systemd-resolved, set it for the interface instead. Debian names interfaces like ens18 or enp1s0; ip -br link lists yours, so replace ens18 below. The second line makes this interface the default for DNS; a VPN’s more specific routes still win, and another interface with ~. shares the queries:

bash
resolvectl dns ens18 192.0.2.53
resolvectl domain ens18 '~.'

resolvectl query debian.org should end with link: ens18. It’s temporary: NetworkManager or networkd may overwrite it when the link changes, and a reboot always does. resolvectl revert ens18 undoes it by hand.

macOS

networksetup -listallnetworkservices lists your connections; replace Wi-Fi if yours has another name:

bash
sudo networksetup -setdnsservers Wi-Fi 192.0.2.53

scutil --dns | grep nameserver should show 192.0.2.53 first. networksetup -getdnsservers Wi-Fi shows any servers set by hand; sudo networksetup -setdnsservers Wi-Fi empty returns it to automatic DNS.

Watch the dashboard

Browse for a few minutes, then open the console’s dashboard. You should see the queries arriving, the cache hit rate climbing, and nothing blocked yet; blocking is part 4.

If nothing arrives, two things commonly go around a resolver set this way: an IPv6 DNS server your router also hands out, which the client may keep using, and a browser with its own DNS-over-HTTPS, which Firefox turns on by default in some countries. Part 3 covers both.

Back up the settings

Settings

At the bottom of the page, Backup Settings saves everything you have set up as one zip file. Every box except Log Files is ticked by default; leave them as they are. Keep the file somewhere other than this host, and keep it like a password: it holds your user accounts as well as the settings. Restore Settings, next to it, puts it all back on a fresh install.

Hand it out by DHCP

When the client works for a day, hand the resolver out through your router’s DHCP. Until part 5 adds a second node, this one host is every device’s DNS: when it’s down, nothing resolves, so know how to point the router’s DHCP back at its old DNS server. Then move on to giving it a real name in part 2.

After part 2: put a real certificate on the console

Out of the box the console speaks plain HTTP on 5380 and nothing else, which is why the firewall keeps 5380 closed and you have been reaching it through SSH. This step gives it HTTPS on 53443, so you and your scripts can reach it across the network. It needs a certificate for the resolver’s name; part 2 of this series makes a wildcard that covers it. Come back here with the certificate, its chain and the key, and do the steps in this order.

Technitium wants a PKCS #12 bundle, a .pfx or .p12 file, not loose PEM files. Build one from the certificate, the chain and the key, and keep it under /etc/dns, which the installer already owns on behalf of the service user and re-owns on every upgrade:

bash
mkdir -p /etc/dns/tls
openssl pkcs12 -export -in resolver.crt -certfile chain.pem -inkey resolver.key -out /etc/dns/tls/resolver.pfx
chown -R dns-server:dns-server /etc/dns/tls
chmod 600 /etc/dns/tls/resolver.pfx

openssl asks for an export password; you will type it into the console in a moment.

Settings › Web Service

  1. Tick Enable HTTPS. Set TLS Certificate File Path to /etc/dns/tls/resolver.pfx and enter the export password. Leave Use A Self Signed TLS Certificate When TLS Certificate File Path Is Unspecified unticked; it is only a fallback for when no file is set. Save.
  2. Open https://resolver.example.com:53443 by name, not by address; a wildcard only matches a name. Confirm the browser shows the real certificate with no warning.
  3. Point every script that talks to the API at https://resolver.example.com:53443, by name. Port 5380 was never open to your network, so plain HTTP was never an option for them.
  4. Leave Enable HTTP to HTTPS Redirection off. The only HTTP left is your SSH tunnel, and the redirect would send it to a port the tunnel doesn’t carry. Leave Enable HTTP/3 off too; nothing you run benefits from it.

Where the file lives: since 15.0 the service runs as its own user, dns-server, with the filesystem read-only to it apart from /etc/dns and its log directory. A certificate elsewhere works if that user can read it and it isn’t under /home or /root, which the service can’t see at all; ownership is then yours to manage. Under /etc/dns the installer sets it for you on every run, and Backup Settings includes the bundle, private key and all, so keep backups made from now on as carefully as the key itself. Technitium also checks the file once a minute and reloads it when it changes, so when part 2’s renewal replaces the bundle, nothing needs restarting.

In a cluster, the Web Service tab is per node. The Settings page has a dropdown that switches between the cluster’s shared settings and each node’s own; the certificate path is set on each node, and the file has to exist on each node’s disk. The cluster syncs settings, not files.