Linux

Install Technitium DNS Server on Ubuntu, Debian and Rocky Linux

A resolver that blocks ads, an authoritative server for internal names, a DHCP server, and something that speaks DNS-over-HTTPS. On most networks that is four packages, four config formats, and four things to forget about until one of them breaks. Technitium DNS Server is a single process that does all of it, with a web console and a full HTTP API on top.

Original content from computingforgeeks.com - post 170415

This guide covers how to install Technitium DNS Server four ways: the bare-metal installer on Ubuntu, on Debian, on Rocky Linux (where the installer deliberately leaves firewalld and SELinux alone), and as a Docker Compose deployment configured entirely from environment variables. After that it gets hardened, wired up with encrypted upstreams and block lists, tested against real queries, migrated onto from Pi-hole or BIND 9, automated through its API, and scraped by Prometheus.

Tested in August 2026 on Technitium 15.4 across Ubuntu 26.04, Debian 13, Rocky Linux 10 and Docker. Every number below came off those boxes rather than a spec sheet.

What Technitium replaces, and when to skip it

The pitch is consolidation. One binary answers recursive queries, serves your own zones authoritatively, signs them with DNSSEC, blocks domains from hosts-format lists, terminates DoH/DoT/DoQ, and leases addresses over DHCP. Nothing else in the mainstream does that whole set in one process, and the table below is deliberately generous to the alternatives: BIND 9 has done native DoT and DoH since 9.18 and can block with Response Policy Zones, and Unbound serves both encrypted transports too. The difference is that you assemble those capabilities yourself, per daemon, in separate config languages.

ToolRecursiveAuthoritativeBlockingDoH/DoT serverDHCPGUI
Technitiumyesyesyesyesyesyes
Pi-hole / AdGuard Homeforwardsnoyespartialyesyes
BIND 9yesyesvia RPZyes, since 9.18nono
PowerDNSseparate daemonyesvia RPZ (Recursor)via dnsdistnoadd-on
Unboundyesminimalvia RPZyesnono
dnsmasqforwardsminimalhosts filenoyesno

Skip it if you are serving authoritative DNS for hundreds of public zones, where BIND or PowerDNS with a database backend is still the better answer. Skip it if you need the smallest possible resolver on constrained hardware, because the .NET runtime costs real memory (measured further down). And skip it if your team already has BIND expertise and zone files in Git, since moving to a GUI-first server with an API is a workflow change, not just a package change. Our comparison of BIND, dnsmasq, PowerDNS and Unbound is the wider view if you are still choosing.

Prerequisites and sizing

Three things drive the spec on a Technitium box, and none of them is CPU. Block lists are the first: every list is parsed into memory at load, so a single 99,000-entry hosts list costs real RAM and a five-list setup costs several times that. Cache size is the second, tunable by maximum entries rather than bytes. Log and stats retention is the third, and it lands on disk rather than RAM.

For a LAN resolver serving a few hundred clients, 2 vCPU and 2 GB of RAM is genuinely enough, and the honest reason is that DNS is cheap: measured resident memory on all four installs below sat between 163 MB and 176 MB with a 99,000-zone block list loaded. Push into thousands of clients, several block lists and DNSSEC signing of your own zones and 4 GB is the comfortable number. Anything that terminates DoH for the public internet should be sized on TLS handshakes, not queries.

The boxes in this guide ran 2 vCPU, 3 GB to 4 GB of RAM and a 20 GB disk. Treat that as a floor for following along rather than a production recommendation.

You also need a few things the cloud images do not ship. The Debian and Rocky Linux cloud images arrive without dig, and the Rocky cloud image has no firewalld installed at all, which matters because a normal Rocky install does. Install the client tools on whichever box you will be testing from:

sudo apt-get install -y bind9-dnsutils      # Ubuntu, Debian
sudo dnf install -y bind-utils firewalld    # Rocky, AlmaLinux, RHEL

Port 53 must be free, which on Ubuntu and Debian means dealing with systemd-resolved. The installer handles that for the bare-metal paths and it is the one manual step for Docker, both covered below.

Set the variables every step uses

Several values repeat across firewall rules, zone records, API calls and the Compose file. Export them once so you can paste the rest of the guide without hunting for substitutions:

export DNS_HOST="10.0.1.53"
export DNS_FQDN="ns1.lab.example.com"
export ZONE="lab.example.com"
export LAN_CIDR="10.0.1.0/24"
export TDNS_PASS="ChangeMe-Strong-Passphrase"

Swap in your own server address, hostname, internal zone and LAN range, pick a real passphrase, then confirm nothing is empty before you run anything that depends on them:

echo "server : ${DNS_HOST} (${DNS_FQDN})"
echo "zone   : ${ZONE}"
echo "lan    : ${LAN_CIDR}"

These live only in the current shell. Reconnect or jump into sudo -i and you need to export them again.

Install on Ubuntu and Debian

The upstream installer is one pipe, and it is the same command on both distributions:

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

Do not run that yet. There is a dependency trap worth two minutes of prevention, and it is the single biggest reason to read this section instead of the upstream one-liner.

The ICU fallback installs 137 MB where 36 MB is needed

Technitium runs on .NET, which needs ICU for globalization. The installer looks for libicu74, libicu72 and libicu70 by name. Debian 13 ships libicu76 and Ubuntu 26.04 ships libicu78, so on a current release none of those names match, and on a box with no ICU at all the script falls through to a wildcard:

No specific libicu package was found, trying generic installation...

That generic installation is apt-get install -y libicu*, and the glob does exactly what a glob does. On Debian 13 it pulled eleven packages totalling 137 MB, of which one package and 36 MB were actually required:

PackageInstalled sizeNeeded?
libicu7637,371 KByes
libicu-dev50,061 KBno
libicu4j-java17,429 KBno, this is Java
libicu4j-4.4-java6,635 KBno, this is Java
libc6-dev, linux-libc-dev, manpages-dev, libc-dev-bin, libcrypt-dev, rpcsvc-proto, icu-devtools28,935 KBno, partial C toolchain
Total installed140,431 KB

Two unrelated Java libraries and a C build toolchain on a DNS server is not a disaster, but it is avoidable and it widens the patch surface of a box whose whole job is answering port 53. Install the right ICU package first and the glob never runs. Rather than hardcoding a number that goes stale next release, let apt name the package:

sudo apt-get update
ICU_PKG=$(apt-cache search --names-only '^libicu[0-9]+$' | awk '{print $1}' | sort -V | tail -1)
echo "installing ${ICU_PKG}"
sudo apt-get install -y "${ICU_PKG}"

On Ubuntu 26.04 that resolves to libicu78 and on Debian 13 to libicu76. The wildcard branch only runs when no libicu package is installed at all, which is exactly how the two runs diverged: the Ubuntu 26.04 image already carried libicu78, so the installer skipped the whole chain, while the Debian 13 image carried no libicu, so the glob fired and dragged in the other ten packages. Satisfy ICU first and the installer says so in one line:

ICU package is already installed.

Now run the installer. It fetches the ASP.NET Core runtime from Microsoft into /opt/dotnet, symlinks /usr/bin/dotnet, drops the application in /opt/technitium/dns, and finishes in well under half a minute: 11.65 seconds on Ubuntu against 17.18 seconds on Debian, where the extra time was the 137 MB of ICU packages the glob pulled before this fix existed.

What the installer changed, and the unit name nobody guesses

Two surprises are worth knowing before you go looking for them. The service is called dns.service, not technitium, and it runs as the system user dns-server:

systemctl status dns.service
sudo ss -lntupH | awk '/:53 |:5380/{print $1, $5, $7}'

The listener set tells you what a fresh install exposes, and it is only the resolver plus the plain-HTTP console. DoT on 853 and DoH on 443 stay switched off until you enable them:

udp 0.0.0.0:53 users:(("dotnet",pid=1139,fd=221))
tcp 0.0.0.0:53 users:(("dotnet",pid=1139,fd=222))
tcp *:5380 users:(("dotnet",pid=1139,fd=220))

The second surprise is that the installer stops and disables systemd-resolved, forces dns=none into /etc/NetworkManager/NetworkManager.conf if that file exists, and replaces /etc/resolv.conf with nameserver 127.0.0.1. It takes a backup first. On Ubuntu and Debian that backup is useless, and this trips people up at the worst possible moment.

The resolv.conf backup is a dangling symlink on Ubuntu and Debian

The installer copies /etc/resolv.conf with cp -a. On Ubuntu and Debian that path is a symlink to ../run/systemd/resolve/stub-resolv.conf, so cp -a preserves the symlink rather than the contents. The installer then disables systemd-resolved, which deletes the target. Read the backup afterwards and you get nothing:

sudo cat /opt/technitium/dns/resolv.conf.bak

The file exists as a link, the link points at a file that no longer does, and the documented recovery (“restore the backup”) cannot work:

cat: /opt/technitium/dns/resolv.conf.bak: No such file or directory

If dns.service then fails to start, that box has no working resolver and no backup to fall back on. The recovery that does work puts systemd-resolved back and recreates the symlink by hand:

sudo systemctl enable --now systemd-resolved
sudo ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
resolvectl status | head -5

On Rocky Linux the same backup is a real file with real contents, because systemd-resolved was never in the picture. Worth knowing which family you are on before you need it.

Install on Rocky Linux, then fix what the installer skipped

The installer itself is the same command and finished in 12.03 seconds on Rocky Linux 10, using the libicu package that is already present. The RHEL family has no version-numbered ICU chain, so the wildcard problem from the previous section does not exist here.

What the installer does not do on Rocky is touch the firewall, and that is not an oversight to work around quietly. A default Rocky install allows three services in the public zone:

sudo firewall-cmd --list-all | grep -E '^  (services|ports):'

DNS is not among them, so the server answers itself perfectly and nothing else on the LAN can reach it:

  services: cockpit dhcpv6-client ssh
  ports:

Query it from another host in that state and you get a network error rather than a DNS error, which sends people digging through Technitium settings when the packet never arrived:

dig @10.0.1.53 cloudflare.com

The give-away is “host unreachable” rather than SERVFAIL or REFUSED:

;; communications error to 10.0.1.53#53: host unreachable

Open the resolver, the console, and the optional encrypted protocols. One flag per invocation, because firewall-cmd refuses to mix --add-service and --add-port in a single call:

sudo firewall-cmd --permanent --add-service=dns
sudo firewall-cmd --permanent --add-port=5380/tcp
sudo firewall-cmd --permanent --add-port=853/tcp
sudo firewall-cmd --permanent --add-port=853/udp
sudo firewall-cmd --permanent --add-port=443/tcp
sudo firewall-cmd --permanent --add-port=443/udp
sudo firewall-cmd --reload

With the rules loaded the same dig resolves and the console answers HTTP 200 from across the network. If you are only running a resolver and never plan to terminate encrypted DNS, drop the 853 and 443 lines instead of opening ports you do not use.

SELinux stays enforcing, because nothing confines the service

Readers expect SELinux pain here and there is none, which is worth stating plainly along with the reason. The shipped unit carries no policy, so the process lands in the catch-all domain:

getenforce
ps -eZ | grep dotnet
sudo ausearch -m avc -ts recent 2>&1 | tail -1

Enforcing mode, zero denials, and the label that explains why:

Enforcing
system_u:system_r:unconfined_service_t:s0    1139 ?  00:00:12 dotnet
<no matches>

So there is nothing to fix, and also nothing protecting you: unconfined_service_t means SELinux is not constraining what this process can touch. Never disable SELinux to make it work, because it already works. If you want confinement you are writing a policy module yourself.

Run it in Docker with Compose

The container is the most reproducible path, because Technitium accepts its entire first-boot configuration through DNS_SERVER_* environment variables. No clicking through the console to get a working resolver.

First, port 53 on the host. With systemd-resolved holding its stub listener, publishing the port fails with an error that names the collision precisely. This is the published-ports form of the clash; in host mode the same busy port shows up instead as a bind failure from Technitium itself in docker compose logs:

Error response from daemon: failed to set up container networking: driver failed
programming external connectivity on endpoint dns-server (4e79e99cb18a...): failed to
bind host port 0.0.0.0:53/tcp: address already in use

Most guides tell you to disable systemd-resolved. Turning off only the stub listener is better, because resolved keeps doing its other jobs and the change is one file you can delete later. Create the drop-in:

sudo mkdir -p /etc/systemd/resolved.conf.d
sudo vim /etc/systemd/resolved.conf.d/99-technitium.conf

Two lines are all it needs:

[Resolve]
DNSStubListener=no

Restart resolved and confirm that port 53 is free while the service itself is still running:

sudo systemctl restart systemd-resolved
systemctl is-active systemd-resolved
sudo ss -lnup | grep -q '127.0.0.53:53' && echo "stub still up" || echo "stub listener gone"

One step that most guides skip, and it will strand you: /etc/resolv.conf is still a symlink to stub-resolv.conf, which points every lookup at 127.0.0.53, and nothing listens there any more. resolved deliberately does not rewrite that file while the stub is disabled, so the host now has no working resolver, and the docker compose up below cannot even pull the image. Point the symlink at the non-stub file, which lists the real upstream servers:

sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf
getent hosts registry-1.docker.io

If that returns addresses, the host can resolve again and the pull will work. Once Technitium is running you can point /etc/resolv.conf at 127.0.0.1 so the box uses its own resolver. Worth being explicit about how this bit was caught: the lab host appeared to work with the broken symlink because a VPN client had already rewritten those files, which is exactly the kind of local accident that turns into a reader’s dead server.

With the port free and DNS still working, the remaining decision is the container’s network mode, and it is not a stylistic one.

Use host networking, and here is the proof why

Upstream recommends host networking for DHCP. The reason to use it even without DHCP is that bridge mode quietly breaks both your security policy and your visibility, and this is the finding that cost the most time to pin down.

Deployed in bridge mode with recursion restricted to the LAN, every single query came back REFUSED, including queries from the Docker host itself. Docker source-NATs traffic to the Compose network gateway, so Technitium never sees the client address. It sees one address, forever. The dashboard shows the whole story once you switch modes with the same data volume attached:

Top Clients (last hour)
  172.18.0.1    8 hits     <- every client during bridge mode, collapsed into one
  10.0.1.10     5 hits     <- the real client, after switching to host mode

That has three consequences. An access list written with your real LAN range refuses everything. Widening it to the bridge subnet appears to fix things but actually allows anyone who can reach the published port, which is how a resolver becomes an open one by accident. And per-client statistics plus query logs are worthless, because there is only ever one client.

Create the password file first so the credential never sits in the Compose file:

mkdir -p ~/technitium/secrets
printf '%s' "${TDNS_PASS}" > ~/technitium/secrets/dns_admin_password
chmod 600 ~/technitium/secrets/dns_admin_password

Then write the Compose file:

vim ~/technitium/compose.yaml

Every value below was confirmed to land in the running configuration afterwards, and the image tag is pinned deliberately so a redeploy cannot pull a different release than the one you tested. Check the upstream release list before bumping it:

services:
  dns-server:
    container_name: dns-server
    image: docker.io/technitium/dns-server:15.4.0
    network_mode: "host"
    environment:
      - DNS_SERVER_DOMAIN=ns1.lab.example.com
      - DNS_SERVER_ADMIN_PASSWORD_FILE=/run/secrets/dns_admin_password
      - DNS_SERVER_WEB_SERVICE_LOCAL_ADDRESSES=0.0.0.0
      - DNS_SERVER_FORWARDERS=https://cloudflare-dns.com/dns-query (1.1.1.1), https://dns.quad9.net/dns-query (9.9.9.9)
      - DNS_SERVER_FORWARDER_PROTOCOL=Https
      - DNS_SERVER_RECURSION=UseSpecifiedNetworkACL
      - DNS_SERVER_RECURSION_NETWORK_ACL=10.0.1.0/24, 127.0.0.1
      - DNS_SERVER_ENABLE_BLOCKING=true
      - DNS_SERVER_BLOCK_LIST_URLS=https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
      - DNS_SERVER_LOG_USING_LOCAL_TIME=true
    volumes:
      - config:/etc/dns
      - ./secrets/dns_admin_password:/run/secrets/dns_admin_password:ro
    restart: unless-stopped
volumes:
  config:

Bring it up and watch the first boot apply the environment:

cd ~/technitium
sudo docker compose up -d
sudo docker compose logs --tail 20 dns-server

If you have a reason to stay on bridge networking (a host where you cannot free the ports, for instance) then publish 53/udp, 53/tcp and 5380/tcp, set the recursion ACL to the bridge subnet with your eyes open, and add the sysctl that upstream sets for a reason:

    sysctls:
      - net.ipv4.ip_local_port_range=1024 65535

Without that range a busy resolver runs out of source ports for outbound queries inside the container namespace. One more reason to avoid this shape: Docker inserts its published-port rules in the nat table, ahead of the chains ufw filters on, so a published 5380 is reachable regardless of the ufw rule added in the hardening section below. You would think the console was firewalled and it would not be.

First login: replace the default credentials

Open http://10.0.1.53:5380. What you log in with depends on which path you took, and this is the one place where the four diverge in a way that matters. The three installer paths create the administrator account with the password admin, on a service listening on port 53, so that is the first thing to change. The Compose deployment already set it: Technitium reads the first line of the file named by DNS_SERVER_ADMIN_PASSWORD_FILE when it initialises the account, so a Docker reader is already on ${TDNS_PASS} and can skip the password change entirely.

Credit where it is due on the rest of the defaults. The recursion default is not reckless: a fresh install ships with AllowOnlyForPrivateNetworks, so it is not an open resolver the moment it boots. Blocking is enabled with no lists attached on the installer paths, which is why nothing is actually blocked until you add one (the Compose file above attaches one at first boot), and query logging is off everywhere, which explains an empty Logs tab on a server that is clearly working.

Set the current password into a variable so the rest of the guide works whichever path you took, then take a token. Every later API call reuses it:

CURRENT_PASS="admin"              # installer paths
# CURRENT_PASS="${TDNS_PASS}"     # Compose path: the password file already set this

TOKEN=$(curl -s "http://${DNS_HOST}:5380/api/user/login?user=admin&pass=${CURRENT_PASS}&includeInfo=false" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
echo "${TOKEN:0:8}..."

If that prints a Python traceback instead of eight characters, the login was rejected and you picked the wrong line: the server answered with an error object that has no token field. Keep this shell open, because ${TOKEN} is a session token and re-logging in is the only way back. The automation section later swaps it for one that does not expire.

On the installer paths, change the password now. The API call wants the current password as well as the new one, and passing only the new one returns a bare error with no explanation. In the console the same thing lives under Change Password in the account menu at the top right, next to My Profile, not under Administration (whose tabs are Sessions, Users, Groups, Permissions, SSO and Cluster):

curl -s -H "Authorization: Bearer ${TOKEN}" \
  "http://${DNS_HOST}:5380/api/user/changePassword?pass=${CURRENT_PASS}&newPass=${TDNS_PASS}"

Both of those calls put a password in the URL, so it lands in your shell history and in the server’s own request log. The endpoints accept a POST body too, which is what a script should use. Verify the change took by trying the old credentials, which should now be rejected.

Then set a recursion policy that names your network explicitly instead of relying on the private-network heuristic. Settings then Recursion, choose “Use Specified Network Access Control List”, and list the range you exported as ${LAN_CIDR}. Docker readers can confirm rather than change this, because the Compose file set both the mode and the list at first boot:

Technitium DNS Server recursion settings showing the network ACL restricting recursion to the LAN

The ACL is evaluated in listed order and a leading ! denies, so !10.0.1.99 above 10.0.1.0/24 excludes one host from an otherwise permitted range. Anything that matches nothing falls through to the default, which denies everything except loopback. That is the behaviour you want.

Do not leave the console reachable from everywhere in the meantime, and this needs doing on all four paths rather than just the one with a firewall in front of it. Rocky opened 5380/tcp to the whole zone earlier in this guide; the Compose file binds the console to every address; and the Ubuntu and Debian installs have no firewall running at all on a stock cloud image, which makes them the most exposed of the four. On Rocky, swap the blanket port rule for one scoped to your management subnet:

sudo firewall-cmd --permanent --remove-port=5380/tcp
sudo firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=${LAN_CIDR} port port=5380 protocol=tcp accept"
sudo firewall-cmd --reload

If you skipped the firewall step earlier, the first command returns Error: NOT_ENABLED: 5380:tcp and exits non-zero, which is harmless. Skip it and run the other two.

Still on Rocky, confirm the swap took, because a leftover blanket rule would keep the console open while looking like it had been fixed. Ports and rich rules need separate calls, since firewall-cmd will not list both in one invocation:

sudo firewall-cmd --list-ports
sudo firewall-cmd --list-rich-rules

Port 5380 is gone from the port list (firewalld reorders what is left) and the rich rule names your subnet:

443/tcp 853/tcp 443/udp 853/udp
rule family="ipv4" source address="10.0.1.0/24" port port="5380" protocol="tcp" accept

On Ubuntu and Debian there is no firewalld, so use ufw. Ubuntu ships it installed but inactive; the Debian cloud image does not ship it at all, so install it there first. Add the SSH rule before enabling anything, or you will lock yourself out of a remote box:

sudo apt-get install -y ufw          # Debian only, Ubuntu already has it
sudo ufw allow OpenSSH
sudo ufw allow 53/tcp
sudo ufw allow 53/udp
sudo ufw allow from ${LAN_CIDR} to any port 5380 proto tcp
sudo ufw --force enable
sudo ufw status verbose

Status: active is the line that confirms the enable worked. The resolver stays open to everything, which is what you want on a LAN DNS server, while the console is restricted to one subnet:

Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp (OpenSSH)           ALLOW IN    Anywhere
53/tcp                     ALLOW IN    Anywhere
53/udp                     ALLOW IN    Anywhere
5380/tcp                   ALLOW IN    10.0.1.0/24
22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)
53/tcp (v6)                ALLOW IN    Anywhere (v6)
53/udp (v6)                ALLOW IN    Anywhere (v6)

Seven rows for four commands, because ufw adds an IPv6 twin for every rule that does not name an address. The 5380/tcp rule has no twin: it names an IPv4 range, and so does the Rocky rich rule. That fails closed, which is the safe direction, but it will surprise you if you administer the box over IPv6, because the console will simply stop answering. Add an equivalent v6 rule for your management prefix if that is how you connect.

On the Docker path the equivalent is narrowing DNS_SERVER_WEB_SERVICE_LOCAL_ADDRESSES from 0.0.0.0 to the management interface address. With host networking the host firewall applies as normal, so the ufw or firewalld rules above cover it too.

While you are in Settings, the Web Service tab is where you bind the console to a single interface rather than every address, and where TLS for the console gets configured. Technitium wants a PKCS#12 .pfx file with the private key included, which is not what certbot emits. That conversion is where most attempts fail, so here it is:

sudo openssl pkcs12 -export \
  -out "/etc/dns/${DNS_FQDN}.pfx" \
  -inkey "/etc/letsencrypt/live/${DNS_FQDN}/privkey.pem" \
  -in "/etc/letsencrypt/live/${DNS_FQDN}/fullchain.pem" \
  -passout pass:CHANGE_THIS_PFX_PASSWORD

The sudo matters on both commands: certbot creates /etc/letsencrypt/live at 0700 and privkey.pem at 0600, both owned by root. Two things then go wrong more often than anything else. The bundle has to actually contain the private key, and the file has to be readable by the dns-server user:

sudo openssl pkcs12 -in "/etc/dns/${DNS_FQDN}.pfx" -nodes -passin pass:CHANGE_THIS_PFX_PASSWORD \
  | grep -c 'BEGIN PRIVATE KEY'
sudo chown dns-server:dns-server "/etc/dns/${DNS_FQDN}.pfx"
sudo chmod 600 "/etc/dns/${DNS_FQDN}.pfx"

You want exactly 1 there. Count the key rather than every PEM object, because fullchain.pem carries the leaf plus at least one intermediate, so a real Let’s Encrypt bundle holds three objects and only a single self-signed certificate would hold two.

Then point the web service at it under Settings, Web Service: tick Enable HTTPS, set the TLS Certificate File Path and TLS Certificate Password, and the HTTPS port defaults to 53443. Read the next sentence before you walk away, because this is the part that catches people. Enabling HTTPS adds the 53443 listener; it does not retire 5380, which keeps serving the console over plain HTTP. Tick Enable HTTP to HTTPS Redirection as well, or the unencrypted console you just went to the trouble of firewalling is still serving on 5380. With redirection on, 5380 keeps listening but answers with a redirect instead of the console.

One disclosure on this step: the conversion, the key check and the ownership were verified against a locally generated certificate and key rather than a live Let’s Encrypt issuance, so treat the certbot paths as the shape rather than as a captured run. Automating the re-export on every renewal, and serving DoH and DoT to clients rather than only securing the console, is a bigger job that gets its own guide in this series.

Wire the resolver: DoH upstreams, block lists, a local zone

A resolver with no upstream policy, no lists and no local zone is just a cache. Three settings turn it into the thing you actually wanted.

Forwarders come first. Technitium takes a URL plus a bracketed IP hint, which lets it reach a DoH endpoint without needing a working resolver to look up the endpoint’s own name first. That bootstrap detail is easy to miss and it is why the addresses are in there:

https://cloudflare-dns.com/dns-query (1.1.1.1)
https://dns.quad9.net/dns-query (9.9.9.9)

Set the protocol to DNS-over-HTTPS under Settings then Proxy & Forwarders. Concurrent Forwarding is on by default with a concurrency of 2, which queries both upstreams and takes whichever answers first:

Technitium DNS Server forwarder settings with DNS-over-HTTPS upstream resolvers selected

One note the console gives you and most write-ups do not: the https scheme covers DoH over HTTP/1.1 and HTTP/2 only. For HTTP/3 you write h3:// instead, and there is no protocol fallback if the connection fails. Pick that deliberately rather than by accident.

Block lists go under Settings then Blocking. Add list URLs, leave the blocking type on NX Domain, and the server returns NXDOMAIN for matched names rather than pointing them at a black hole address:

Technitium DNS Server blocking settings with NX Domain blocking type enabled

Lists refresh on a timer, and you can force a refresh instead of waiting. On the lab server the single StevenBlack list loaded 99,275 zones:

curl -s -H "Authorization: Bearer ${TOKEN}" \
  "http://${DNS_HOST}:5380/api/settings/forceUpdateBlockLists"

Last comes your own zone, which is where Technitium starts earning its place over Pi-hole. Zones, Add Zone, Primary, then add records. Six clicks gets you what a zone file plus a reload gets you in BIND:

Technitium DNS Server primary zone record table with A, NS and SOA records

The DNSSEC menu on that same zone page signs it, and the zone transfer settings support TCP, TLS and QUIC with TSIG keys. BIND 9.18 also does zone transfer over TLS in both directions, so the honest claim is narrower than most write-ups make it: QUIC is the transport BIND does not offer. Primary and secondary zones with signing are involved enough to deserve their own walkthrough, so this guide stops at a signed-capable primary.

Test it properly

Four behaviours are worth confirming, and each one fails differently, so run them individually rather than assuming a working dig google.com proves anything:

dig +short @${DNS_HOST} www.${ZONE}
dig @${DNS_HOST} doubleclick.net | grep -oE 'status: [A-Z]+'
dig +dnssec @${DNS_HOST} cloudflare.com | grep -m1 -oE 'flags: [a-z ]+'
dig +short @${DNS_HOST} github.com

Authoritative answer from your own zone, NXDOMAIN for a blocked name, the ad flag proving the upstream chain validated DNSSEC, and a normal recursive answer:

10.0.1.50
status: NXDOMAIN
flags: qr rd ra ad
140.82.121.4

Then check you have not built an open resolver. This one only means anything from a source address the ACL does not cover, so running it again from the host you have been testing on proves nothing. Either use a second machine on a subnet you did not list, or add a spare address outside the range and query as that source. dig -b can only bind an address the host actually holds, so add it first:

IFACE=$(ip route show default | awk '{print $5; exit}')
sudo ip addr add 10.0.2.99/24 dev "${IFACE}"
dig -b 10.0.2.99 @${DNS_HOST} example.com | grep -oE 'status: [A-Z]+'
sudo ip addr del 10.0.2.99/24 dev "${IFACE}"

REFUSED is the pass condition. An answer means the ACL is wider than you think, and if the server also has a public address then you have just published an amplification source. Empty output is not a pass: it means the reply could not route back to that made-up source, so retest from a host the server can actually answer:

status: REFUSED

The dashboard is the other half of the test, because it shows whether the server is doing what you think. After a scripted mix of ordinary and ad-network lookups, the counters split the way they should: mostly cache hits, a fifth blocked, a slice answered authoritatively from the local zone.

Technitium DNS Server dashboard showing total queries, cached, blocked and authoritative counts

Scroll down and the per-client table is the panel that tells you whether your deployment kept client identity intact. One real LAN address here rather than a container gateway is the difference between usable and useless statistics:

Technitium DNS Server top clients panel showing a single LAN client and block list zone count

With behaviour confirmed, the next question is whether the choice of install path costs you anything measurable.

Does the install method affect query latency?

No. Measured from the same client against all four installs, the medians are indistinguishable, and Docker is not the slow one:

Install pathAuthoritativeCachedUncachedResident memory
Ubuntu, bare metal1.0 ms1.0 ms127 ms176 MB
Debian, bare metal1.0 ms1.0 ms131 ms163 MB
Rocky Linux, bare metal1.0 ms1.0 ms129 ms167 MB
Docker, host networking1.0 ms1.0 ms129 ms163 MB

Pick the path that fits how you manage the rest of your infrastructure, not the one you think is faster. The memory figures are the real trade-off: 163 MB to 176 MB is the .NET runtime floor, and Unbound or dnsmasq will do the resolver-only job in a fraction of that.

Which upstream transport costs the least?

DoT is effectively free compared to plain UDP, and DoH costs roughly 10 ms to 30 ms plus a worse tail. Getting that answer honestly took two attempts, and the first one was wrong in a way worth describing, because it is an easy trap.

Querying random subdomains to force cache misses measures the wrong thing: it pushes the upstream into a full delegation walk, so every transport floored at about 125 ms and the ranking flipped between runs. The fix is to flush the local cache before each query and ask for popular names the upstream already has cached, so what remains is mostly our own transport cost. With that method, 40 samples per mode, three runs across two hosts:

TransportRun 1 medianRun 2 medianRun 3 medianWorst case seen
DNS-over-HTTPS88 ms78 ms68 ms261 ms
DNS-over-TLS65 ms56 ms55 ms79 ms
Plain UDP60 ms61 ms58 ms96 ms
Plain TCP56 ms56 ms62 ms71 ms

Those medians come from a harness that drives the server through its own API and flushes the cache between queries, so the absolute numbers sit lower than the uncached figures in the previous table, which were measured with dig from a client. Compare the transports against each other, not against the other table.

Two conclusions survive scrutiny. DoH was slowest in all three runs and owns the worst tail. DoT, UDP and TCP cluster between 55 ms and 65 ms, and the gaps between those three sit inside run-to-run noise, so no ordering among them is claimed here. Choose DoH because it traverses firewalls and resists inspection, not because it is quick. If you want encrypted upstreams with the least latency cost, DoT is the pick, and DNSCrypt is the other approach to the same problem.

Migrate from Pi-hole or BIND without a resolution gap

Both migrations follow the same shape: stand Technitium up beside the thing it replaces, move the configuration across, point one client at it, then flip the network. Nothing needs to go down.

Coming from Pi-hole, the two things worth carrying over are the adlists and the local DNS records. Pi-hole keeps its list URLs in its own database, and they go straight into Technitium’s block list URLs field. Two details matter in that query: use pihole-FTL sqlite3 rather than a standalone sqlite3, which Pi-hole does not install, and filter on type=0, because type=1 rows are allow lists. Copy those into a block list field and you will block the domains you meant to permit.

sudo pihole-FTL sqlite3 /etc/pihole/gravity.db \
  "select address from adlist where enabled=1 and type=0;"

Pi-hole’s Local DNS records become records in a Technitium primary zone. If you have more than a handful, do not retype them, because the API takes a zone file directly (covered in the next section). Our Pi-hole in Docker guide has the paths if you are migrating off a containerised install.

Coming from BIND is less work, because Technitium imports RFC 1035 zone files as-is. Create the zone, then POST the file to the import endpoint with a text/plain body:

curl -s -H "Authorization: Bearer ${TOKEN}" \
  "http://${DNS_HOST}:5380/api/zones/create?zone=corp.example.com&type=Primary"

curl -s -X POST -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: text/plain" \
  --data-binary @/var/named/corp.example.com.zone \
  "http://${DNS_HOST}:5380/api/zones/import?zone=corp.example.com&overwrite=true&overwriteSoaSerial=true"

A nine-record zone file with A, CNAME, MX, NS and SOA entries imported without a single edit, and the SOA serial carried across intact:

corp.example.com         SOA    ns1.corp.example.com / [email protected] / serial 2026080301
corp.example.com         MX     10 mail.corp.example.com
api.corp.example.com     CNAME  www.corp.example.com
www.corp.example.com     A      10.0.1.20
db01.corp.example.com    A      10.0.1.31

Note that overwriteSoaSerial matters if secondaries are watching. Setting a serial lower than the current one stops them syncing, so on a live zone let Technitium keep its own serial instead. Existing BIND primary and secondary pairs can keep working through the transition, with Technitium as an additional secondary until you are ready to hand it the primary role.

For the cutover itself, resist the urge to change the DHCP option first. Point one workstation at the new server manually, use it for a day, and only then update the DHCP-advertised resolver. Leave the old server running until client leases have rolled over, since a resolver that vanishes mid-lease produces support tickets that look like application failures.

Automate it with the HTTP API

Everything the console does is an API call, and since 15.0 the authentication is a bearer token in a header. The session token from a login expires; for scripts you want a named token that does not:

curl -s "http://${DNS_HOST}:5380/api/user/createToken?user=admin&pass=${TDNS_PASS}&tokenName=automation"

The response carries a 64-character token. Store it the way you store any other credential, because it is equivalent to console access:

{"username":"admin","tokenName":"automation","token":"REDACTED_64_CHAR_TOKEN","status":"ok"}

From there, record provisioning is idempotent if you let it be. Adding a record that already exists is not an error, so a script that runs on every deploy converges rather than failing:

AUTH="Authorization: Bearer ${TOKEN}"
API="http://${DNS_HOST}:5380/api"

for HOST_REC in "web01:10.0.1.20" "db01:10.0.1.31" "cache01:10.0.1.35"; do
  NAME="${HOST_REC%%:*}"; ADDR="${HOST_REC##*:}"
  curl -s -H "${AUTH}" \
    "${API}/zones/records/add?zone=${ZONE}&domain=${NAME}.${ZONE}&type=A&ttl=3600&ipAddress=${ADDR}&overwrite=true"
  echo " ${NAME}.${ZONE} -> ${ADDR}"
done

The same pattern covers the operational calls you end up wanting in CI: /api/cache/flush when you have changed an answer that was already cached (a brand new record needs no flush, since the cache holds recursive answers rather than your own zones), /api/settings/forceUpdateBlockLists on a schedule, and /api/zones/export to keep a zone file in Git. Query-log retrieval exists as well, but it belongs to the logging DNS app rather than the core server, so it needs that app installed and its class path passed in. The companion repository carries all of these as runnable scripts, along with both Compose files, the Prometheus job, the Grafana dashboard and the transport benchmark.

Production wiring: backup, upgrade, and Prometheus metrics

The entire state of a Technitium server lives in one directory, which makes backup pleasantly boring. Zones, statistics, block list caches, the auth config, DHCP scopes and the DNS settings are all under /etc/dns, so either archive the directory or use the API and get a zip. The API version takes one flag per category and defaults them to off, so name everything you actually want:

curl -s -H "Authorization: Bearer ${TOKEN}" -o tdns-backup.zip \
  "http://${DNS_HOST}:5380/api/settings/backup?dnsSettings=true&zones=true&authConfig=true&blockLists=true&allowedZones=true&blockedZones=true&scopes=true&stats=true&apps=true"
ls -lh tdns-backup.zip

That produced a 723 KB archive on the lab server, most of it the cached block list. Restoring is the matching /api/settings/restore call, and it is worth actually testing on a scratch install before you need it at 2am.

Upgrades are the same installer re-run on bare metal, which keeps the configuration in place, or a tag bump and docker compose up -d in the container. Both preserve /etc/dns. One side effect to know about on the bare-metal path: the resolv.conf backup and rewrite run unconditionally on every invocation, so an upgrade overwrites resolv.conf.bak with the file the installer itself wrote. On Rocky that destroys the only genuinely useful copy you had, so take your own before upgrading. A cosmetic quirk to ignore: a first-time install always prints “Updating Technitium DNS Server” because the script creates the config directory before it checks whether one existed.

Prometheus metric names do not match the documentation

Technitium exposes Prometheus text-format metrics at /api/dashboard/metrics/text, and the endpoint needs the same bearer token as the rest of the API. Upstream marks it experimental and reserves the right to change it, so pin the version you built dashboards against. The trap is the metric names. The API documentation lists total_queries, total_blocked and friends; the running server emits the suffix form. Query the documented names and your panels return NO DATA while everything looks correctly configured.

Ask the server what it actually publishes rather than trusting either the docs or this article:

curl -s -H "Authorization: Bearer ${TOKEN}" \
  "http://${DNS_HOST}:5380/api/dashboard/metrics/text" | grep -v '^#' | awk '{print $1}'

Thirteen series. The eleven counters put _total at the end rather than the start, which is the whole problem:

queries_total
no_error_total
server_failure_total
nx_domain_total
refused_total
authoritative_total
recursive_total
cached_total
blocked_total
dropped_total
clients_total
uptime_seconds
start_time

Add the scrape job with the token as bearer credentials. Open the Prometheus config:

sudo vim /etc/prometheus/prometheus.yml

The non-obvious part is metrics_path, since the endpoint does not live at /metrics:

scrape_configs:
  - job_name: technitium
    metrics_path: /api/dashboard/metrics/text
    static_configs:
      - targets: ["10.0.1.53:5380"]
    authorization:
      type: Bearer
      credentials: "PASTE_THE_API_TOKEN_HERE"

Reload Prometheus and confirm the target is up before you go building panels against it:

curl -s http://localhost:9090/api/v1/targets | grep -oE '"health":"[a-z]+"'

Four expressions cover the panels that matter. Query rate, block rate, cache efficiency, and outcome breakdown, all built from counters so rate() does the work. The clamp_min on the ratio keeps the panel from dividing by zero while the server is idle:

rate(queries_total[1m])
rate(blocked_total[1m])
rate(cached_total[5m]) / clamp_min(rate(queries_total[5m]), 0.0001)
rate(no_error_total[1m])   # repeat for nx_domain_total, refused_total, server_failure_total

Under a live query load that gives you the picture the console cannot: rates over time rather than counters since boot, with 19.7 queries per second, 4.37 blocked per second and a 68.7 percent cache hit ratio on this run.

Grafana dashboard showing Technitium DNS queries per second, blocked per second and cache hit ratio

The refused_total rate deserves an alert rather than a panel. A resolver that suddenly starts refusing queries usually means a client moved to a subnet your ACL does not cover, and it is much easier to catch on a graph than in a support ticket. Our guide on monitoring DNS servers with Prometheus and Grafana covers alert rules and the exporter approach for servers that do not publish metrics themselves.

Errors this lab produced, and the fix for each

Every message below came off the test boxes rather than an issue tracker.

Error: “failed to bind host port 0.0.0.0:53/tcp: address already in use”

systemd-resolved holds the stub listener on port 53. Add the DNSStubListener=no drop-in from the Docker section and restart resolved. Do not disable resolved entirely unless you have a reason to.

Every query returns REFUSED, including from the Docker host

Bridge networking plus an ACL that names your real LAN. Docker rewrites the source address to the Compose gateway, so no client ever matches. Switch to network_mode: "host". Widening the ACL to the bridge subnet is the tempting fix and the wrong one, because it authorises anyone who can reach the port.

Error: “communications error to 10.0.1.53#53: host unreachable”

This is a network error, not a DNS one, which is the clue. On Rocky Linux, firewalld has not been told about DNS. Apply the firewalld rules from the Rocky Linux section. If the resolver answers correctly on the box itself and nowhere else, it is always the firewall.

Error: “argument –remove-port: not allowed with argument –remove-service”

firewall-cmd will not mix service and port arguments in one invocation, in either direction. Split it into one flag per call.

Error: “cat: /opt/technitium/dns/resolv.conf.bak: No such file or directory”

The file is there as a symlink; its target is not. See the Ubuntu and Debian install section for why, and use the systemd-resolved recovery rather than hunting for the backup contents.

The installer prints “No specific libicu package was found”

Harmless in itself, but it means the wildcard install is about to run and pull around 137 MB of packages you do not need. Interrupt, install the ICU package apt actually offers, and re-run. The installer is idempotent.

Grafana panels show NO DATA with a healthy Prometheus target

Documented metric names instead of emitted ones. Use queries_total rather than total_queries, and check the live list with the metrics command above rather than trusting any document, including this one.

Which install path to run

Since latency and memory came out identical across all four, the decision is about how you want to manage the thing for the next two years.

SituationPathWhy
You want reproducible config in GitDocker with host networkingThe whole server is declared in the Compose file; no click-ops to document
Existing RHEL-family fleet with policyRocky Linux, bare metalFits existing patching, but budget the firewalld work and know the service runs unconfined
Debian or Ubuntu host, no container runtimeBare metal installerFastest to a working resolver; pre-install ICU and know the resolv.conf recovery
You need DHCP from the same boxDocker with host networking, or bare metalDHCP needs the host network namespace to see broadcasts

Whichever you pick, the two things to get right on day one are the recursion ACL and a password that is not admin. Most of the rest can be changed later from the console, though anything touching the web service binding or its certificate restarts that service. Those two are the difference between a resolver you run and a resolver that ends up in somebody’s amplification attack.

Keep reading

Configure Samba File Share on Debian 13 / 12 Debian Configure Samba File Share on Debian 13 / 12 Setup WireGuard VPN on Ubuntu 24.04 / Debian 13 / Rocky Linux 10 Debian Setup WireGuard VPN on Ubuntu 24.04 / Debian 13 / Rocky Linux 10 Configure Samba File Sharing on Linux Mint 22 Networking Configure Samba File Sharing on Linux Mint 22 How to Power a 10-Inch Mini Rack Cleanly Networking How to Power a 10-Inch Mini Rack Cleanly Best 10-Inch Rack Accessories for a Homelab Mini Rack Networking Best 10-Inch Rack Accessories for a Homelab Mini Rack Install NetBox IPAM & DCIM Tool on Ubuntu 22.04|20.04 Networking Install NetBox IPAM & DCIM Tool on Ubuntu 22.04|20.04

Leave a Comment

Press ESC to close