My homelab needed a DNS layer of its own, something that could block ads network wide, answer for internal names like jellyfin.home.lan, and give me visibility into what every device on the network is querying. I picked Technitium for the job, ran it in a small LXC on Proxmox, and worked around the fact that my ISP box refuses to hand out anything but its own DNS over DHCP. Here is how the pieces fit together.

Why Technitium

There is no shortage of options in this space: Pi-hole, AdGuard Home, Unbound, dnsmasq. Technitium sits in a sweet spot for a homelab because it does several jobs in one process. It is a full recursive and authoritative DNS server, it hosts local zones, it supports blocklists with live logs of what gets blocked, and it ships with a built in DHCP server. That last part turned out to matter more than I expected, because it is what let me push a custom DNS to every client on my LAN without touching the ISP box.

The web UI is clean, DNSSEC and DNS over HTTPS are supported out of the box, and the whole thing runs on .NET so it works the same on Linux, Windows or Docker. For a container that will handle every DNS query on the network, a few hundred megabytes of RAM is a comfortable budget.

The architecture

My Proxmox node uses two bridges: vmbr0 faces the home network in 192.168.2.0/24, and vmbr1 is an internal network in 192.168.1.0/24 where my containers live. Technitium sits on vmbr1 alongside my Nginx Proxy Manager, and the host does NAT and port forwarding between the two.

Home devices (192.168.2.x) | v DNS queries on UDP 53 Proxmox host (vmbr0: 192.168.2.100) | v DNAT 53 -> 192.168.1.11 Technitium LXC (192.168.1.11) | +--> forwards public queries to 1.1.1.1 +--> answers *.home.lan from local zones +--> serves DHCP for the LAN

Any DNS query on the LAN reaches the Proxmox host and is silently rewritten to Technitium's internal address. Technitium then answers directly for local names, or forwards the rest upstream and filters the response against its blocklists.

The container

I provision containers from a Debian 13 template with a small script. For Technitium the sizing is modest: one CPU core, 512 MB of RAM, and 8 GB of disk are plenty. The container is unprivileged, gets a static address on vmbr1, and reaches the internet through NAT on the host. I pass --nameserver 1.1.1.1 to pct create so that apt can resolve packages during setup, before Technitium is running.

Installing the .NET runtime

Technitium needs the ASP.NET Core runtime, and Debian 13 does not ship it in its default repositories. The Microsoft repo does not yet publish a channel for Trixie either, so the cleanest path is the official install script:

curl -sSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh
chmod +x dotnet-install.sh
./dotnet-install.sh --channel 10.0 --runtime aspnetcore --install-dir /usr/share/dotnet
ln -s /usr/share/dotnet/dotnet /usr/bin/dotnet
Watch the runtime version

Recent Technitium builds target .NET 10, not 8. Installing the wrong version gives a cryptic "You must install or update .NET to run this application" at start. The message actually spells out the framework version it wanted, and reading it end to end saves a lot of guessing.

Installing Technitium

The portable release ships as a tarball. I extract it under /opt and run it once by hand to confirm it starts and to set the admin password on the web UI at port 5380:

curl -sSL https://download.technitium.com/dns/DnsServerPortable.tar.gz -o /tmp/dns.tar.gz
mkdir -p /opt/technitium/dns
tar -xzf /tmp/dns.tar.gz -C /opt/technitium/dns
dotnet /opt/technitium/dns/DnsServerApp.dll

Then Ctrl+C and switch to a real systemd service so it survives reboots and restarts on its own.

[Unit]
Description=Technitium DNS Server
After=network.target

[Service]
Type=simple
ExecStart=/usr/share/dotnet/dotnet /opt/technitium/dns/DnsServerApp.dll
WorkingDirectory=/opt/technitium/dns
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
Use Type=simple, not Type=notify

The dotnet process does not send the sd_notify ready signal that Type=notify waits for. With notify, systemd keeps trying to restart the service and reports activating (auto-restart), so the unit looks alive from a distance but never actually serves queries. Type=simple is the right choice here.

After systemctl enable --now technitium, I check that the DNS ports are actually open:

ss -ulnp | grep 53
ss -tlnp | grep 5380

Blocklists that do not break the internet

Technitium's Blocking section subscribes to community maintained lists. This is where a homelab DNS is easy to get wrong. My first pick was an aggressive list that silently killed Twitter, Reddit and a few sites I actually use, answering with NXDOMAIN as if those domains had never existed.

The pattern to remember is that a blocked domain and a nonexistent domain give the same error to the browser. There is no visible sign that a list is at fault, only "site cannot be reached". The Logs tab is the fastest way to tell them apart, since a blocked query is clearly labelled there.

Two lists I would recommend as a sane starting point:

I avoid anything with social, extreme or ultimate in the name unless I know exactly what it removes. If a legitimate site stops resolving, the first stop is the Logs tab, and a bogus NXDOMAIN almost always turns out to be a list entry that can be moved into the Allowed list.

Forwarders and local zones

In Settings, Forwarders, I added Cloudflare's 1.1.1.1 so that anything outside my zones travels upstream. In Zones, I created an authoritative zone for home.lan and added an A record for each host I want to reach by name:

alpha.home.lan   ->  192.168.1.1     (Proxmox host)
gamma.home.lan   ->  192.168.1.11    (Technitium)
delta.home.lan   ->  192.168.1.12    (Nginx Proxy Manager)

From then on, every device using Technitium as its resolver can reach a service by a clean local name. I do not usually add a record per service though: application level names like jellyfin.home.lan point at the reverse proxy, and it is the proxy that decides which backend to hit based on the hostname.

HTTPS through Nginx Proxy Manager

To reach the Technitium UI at https://gamma.home.lan instead of http://192.168.1.11:5380, I added a proxy host in NPM. One detail cost me a bit of time: the forward scheme has to be http, not https. Technitium serves plain HTTP on 5380, so pointing NPM at https:// produces a 502 while the backend is perfectly healthy.

TLS lives on the reverse proxy

The SSL certificate configured in NPM protects the client to NPM hop. NPM terminates TLS and speaks plain HTTP to the backend over the internal bridge. That is the normal reverse proxy pattern, and it is fine as long as the internal network is trusted. It also means every service behind the proxy inherits HTTPS for free without having to run its own certificate.

Getting the LAN to use it

This is where it got interesting. My ISP box refuses to distribute anything other than the ISP's DNS servers over DHCP, so I could not just change a field and be done. I considered three approaches.

Setting the DNS by hand on every device works, but it forgets new arrivals and is a maintenance chore. Intercepting DNS transparently on Proxmox with iptables is elegant in theory, but it only works if Proxmox is the LAN gateway, which mine is not. What was left was to disable the ISP box's DHCP and let Technitium do it. Technitium's DHCP server can push its own address as the DNS to every client, which is exactly the piece that was missing.

There was one snag: my Orange TV decoder only works when it gets its lease from the ISP box. The workaround is to give the decoder a static lease on the ISP box, then narrow that DHCP pool to just its one IP. Technitium then serves DHCP for the rest of the LAN and hands out its own address as the resolver. Every new device on the network gets ad blocking and internal name resolution automatically, and the TV keeps working.

Pitfalls worth knowing

A few things tripped me up. None were hard once understood, but each looked like a different bug the first time it appeared.

NXDOMAIN is silent

A domain blocked by a list and a domain that does not exist look identical from the browser. Nothing tells you the DNS is filtering. If a site suddenly stops working, the Logs tab in Technitium is where the truth lives, and moving the domain to the Allowed list is a one click fix.

Type=notify quietly breaks the service

With Type=notify, systemd waits for a signal that dotnet never sends, so the unit loops on auto restart. From the outside systemctl status shows a green looking service that is actually never up. Type=simple makes the loop stop and the ports open.

Wrong scheme on the reverse proxy

Technitium's admin UI is plain HTTP. If Nginx Proxy Manager is set to forward as https, the browser gets a 502 while the backend is perfectly healthy. The certificate on NPM covers the client to NPM leg and is unrelated to the scheme used to reach the backend.

The wrong .NET version fails cryptically

Technitium needs .NET 10, and installing .NET 8 gives a generic "install or update .NET" message at startup. The message also lists the version and channel that were expected, which is faster to read than to guess.

A restrictive blocklist can hide as a network problem

My first list was aggressive enough that I initially thought the container had lost internet access. It had not, it was answering, but with NXDOMAIN for half the sites I tried. Testing against the blocklist by resolving a known good name (example.com) versus a suspected blocked one saves the confusion.

Result

A few weeks in, the setup has been solid. Ads and trackers are gone from every device on the LAN without anything being installed on them, internal services are reachable by clean names over HTTPS, and every new device that joins the Wi-Fi picks up the right DNS on its own. The Technitium dashboard also gives a live view of every query on the network, which is genuinely fun to watch and occasionally surfaces a chatty IoT device I did not know was there.

The most useful lesson was not technical: in a homelab, most bugs are misconfigurations that look like something more serious. A wrong systemd type, a wrong scheme on the reverse proxy, an over eager blocklist. None of them is hard on its own, but each produces a symptom that seems to point elsewhere. Reading logs beats guessing, every single time.