Ilija Matoski

    ← Articles

    Technitium DNS in a container on a UDM Pro Max

    The UDM runs Debian and systemd, so it can run an nspawn container. What it cannot do is let that container talk to the router it is attached to.

    I wanted DNS on the router instead of on a Raspberry Pi sitting next to it, so one less machine has to be alive for the house to resolve names.

    The UDM Pro Max runs Debian 11 with systemd 247, and it can run a systemd-nspawn container, which I know because there was a dead Pi-hole container on it from before the last firmware flash.

    The toolchain isn’t there

    systemd-nspawn, machinectl and machines.target all live in systemd-container, which UniFi OS doesn’t ship.

    All ten of its other dependencies are already on the box, so this is a ~400 KB install and nothing else moves:

    apt-get update
    apt-get install -y systemd-container libnss-mymachines

    It goes away again on every firmware update, which I wrote about separately.

    Building the container

    bullseye’s debootstrap maps trixie to its sid script, so it can build a Debian 13 container, but it can’t verify the archive, because the 2021 keyring predates trixie’s signing keys.

    So install debian-archive-keyring 2025.1 first, then run the following command:

    debootstrap --include=systemd,systemd-sysv,dbus,ca-certificates,curl,openssh-server \
    trixie core /data/custom/machines/core

    The rootfs goes under /data, because that’s the one directory a firmware update doesn’t touch.

    The addon README pins containers to bookworm because the host kernel is 4.19, so I expected trixie to fail, and it doesn’t. glibc 2.41 and systemd 257 boot fine under nspawn 247.

    A macvlan child cannot reach its parent

    The container gets its own MAC and address through a macvlan on br0, so it reaches every other machine on the LAN at layer 2 with no routing and no firewall policy.

    What it can’t reach is 192.168.178.1, because that address lives on br0 and a macvlan child can’t get at its parent’s IP stack.

    It’s a kernel rule, not something you configure around.

    So the router can’t be the container’s gateway, and it can’t be its DNS forwarder either.

    So we need a second macvlan on the host, which is a sibling and not a parent, and that one it can reach. I call it the shim, and it is created with the following commands:

    ip link set br0 promisc on
    ip link add br0.mac link br0 type macvlan mode bridge
    ip addr add 192.168.178.2/23 dev br0.mac noprefixroute
    ip link set br0.mac promisc on
    ip link set br0.mac up
    ip route replace 192.168.178.5/32 dev br0.mac

    Without noprefixroute the shim installs a route for the whole subnet and competes with the bridge that already has one.

    And we need the /32, so the host sends container traffic out of the shim instead of out of br0.

    The container side

    [Exec]
    Boot=on
    ResolvConf=off
    [Network]
    MACVLAN=br0

    nspawn names the interface mv-<parent>, so br0 becomes mv-br0, and we configure it from inside the container in /etc/systemd/network/mv-br0.network:

    [Match]
    Name=mv-br0
    [Network]
    Address=192.168.178.5/23
    Gateway=192.168.178.2
    machinectl start core

    machinectl shell core works, and systemd-run -M core doesn’t, because the host is systemd 247 and the guest is 257, so anything scripted goes through nsenter instead:

    nsenter -t "$(machinectl show core -p Leader --value)" -m -u -i -p -- bash

    Installing Technitium

    I ran it with Private=off first, so it shared the host’s network namespace and had working apt before any of the macvlan work existed.

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

    libicu76 goes in up front because the installer only knows libicu74, 72 and 70, and falls through to apt-get install -y libicu* otherwise.

    What it measured out at

    Path Result
    host to container OK, via the shim
    LAN client to container OK, ARP resolves to the container’s own MAC
    container to shim OK
    container to 192.168.178.1 unreachable, as designed
    container to the internet OK, NAT’d normally

    That last row was the one I wasn’t sure about, and it works because UniFi’s masquerade rule is -o eth8 and doesn’t care about the source.

    The container came up on 192.168.178.7 while the Pi kept serving .5, and the two were swapped once it had been answering correctly for a while.