Proxmox qm CLI VM Build, Part 4: A Real Use Case, DNS with Unbound

Three posts into this series and neither VM has done anything except exist. This one gives VM 103 a real job: a DNS resolver, using unbound. Not Pi-hole. This site already has a full walkthrough for building an HA Pi-hole pair with ad-blocking, and repeating that here would just duplicate it. The point of this post is proving the VM built from qm commands can run something a real network would actually use, not adding a second topic. If you want ad-blocking DNS with a web UI, the Pi-hole guide covers that properly. unbound is the leaner choice for what this post needs: one binary, one config file, no database, no web server.

Installing it, and a real sudo gotcha

$ sudo apt update && sudo apt install -y unbound
-bash: sudo: command not found

Worth explaining rather than skipping past. Debian’s installer only configures the first user for sudo automatically when the root password is left blank during install. Part 1 set a root password, so this VM never got sudo configured at all. The fix used here was logging in as root directly and hand-editing /etc/sudoers with a plain editor:

$ su
# apt install sudo
# nano /etc/sudoers

That works, but it’s the riskier path: a syntax mistake in /etc/sudoers saved by a plain editor can lock sudo out entirely with nothing to catch it. The safer options are usermod -aG sudo anon, which just adds the user to the group Debian’s default sudoers file already grants full access to, or visudo instead of a plain editor if the file genuinely needs hand-editing, since visudo validates syntax before it lets you save.

With sudo sorted, the actual install:

$ sudo apt install -y unbound
$ systemctl status unbound --no-pager
● unbound.service - Unbound DNS server
     Active: active (running)

Running immediately, no configuration needed to start. Debian’s unbound ships as a working local caching resolver out of the box:

$ dig @127.0.0.1 example.com +short
104.20.23.154
172.66.147.243

Real config syntax, not guessed

/etc/unbound/unbound.conf is a single line pulling in everything under /etc/unbound/unbound.conf.d/. Rather than write config from memory, Debian’s own package ships a fully commented reference at /usr/share/doc/unbound/examples/unbound.conf, and that’s where the actual syntax came from:

$ grep -n -i "interface:\|access-control:\|local-data:" /usr/share/doc/unbound/examples/unbound.conf
54:     # interface: 192.0.2.153
343:    # access-control: 127.0.0.0/8 allow
897:    # local-data: "mycomputer.local. IN A 192.0.2.51"

Three directives, confirmed rather than assumed: interface: to listen beyond loopback, access-control: to allow queries from the LAN, local-data: to add a custom record.

$ sudo tee /etc/unbound/unbound.conf.d/lab.conf <<'EOF'
server:
    interface: 0.0.0.0
    access-control: 192.168.1.0/24 allow
    local-data: "vm103.lab. IN A 192.168.1.105"
EOF
$ sudo unbound-checkconf
unbound-checkconf: no errors in /etc/unbound/unbound.conf
$ sudo systemctl restart unbound
$ dig @127.0.0.1 vm103.lab +short
192.168.1.105

unbound-checkconf validates the config before anything restarts, worth running every time rather than restarting blind.

An hour spent debugging a firewall that didn’t exist

Locally, everything worked. Querying the VM from pve, the actual point of standing up a DNS server at all, did not:

$ dig @192.168.1.103 vm103.lab +short
;; communications error to 192.168.1.103#53: connection refused
;; no servers could be reached

Refused, not timed out, from two separate machines on the same subnet. That sent the next hour down a genuine infrastructure debugging chase. ss on the VM confirmed unbound correctly bound to 0.0.0.0:53 on both UDP and TCP, ruling out the application. which nft iptables ufw firewalld came back completely empty, ruling out any host firewall since none of those tools were even installed. Proxmox’s own firewall, checked at the VM, node, and datacenter level, was empty everywhere. Forcing TCP instead of UDP produced the identical refusal. A theory about the ISP router blocking third-party DNS as a security feature seemed plausible given everything else came back clean, and got checked directly against the router’s admin settings. Nothing.

The actual answer was much simpler and had nothing to do with firewalls: 192.168.1.103 was the wrong VM. That address belongs to VM 104, the cloud-init build from Part 2, which has never had unbound installed on it. Every failed query in that entire debugging chase was aimed at a machine with nothing listening on port 53 at all, which is exactly what “connection refused” means when nothing is broken. VM 103’s actual address, confirmed with ip a on the box itself, was 192.168.1.105.

$ dig @192.168.1.105 vm103.lab +short
192.168.1.105
$ dig @192.168.1.105 example.com +short
104.20.23.154
172.66.147.243

Clean, both the custom record and ordinary recursive resolution, querying VM 103’s real address from pve. An hour of firewall archaeology for a one-digit typo in an IP address. Worth keeping in the post exactly as it happened: when a service refuses connections cleanly rather than timing out, and every layer you check comes back innocent, check that you’re actually pointed at the right machine before anything else.

Part 5 closes the series: turning VM 103 into a template with qm template and qm clone, so the next lab on this site starts from a clone instead of a fresh ISO install.