dnsmasq Configuration Deep Dive: A Production-Ready Template and the Port-Conflict Trap
Why I run dnsmasq on the LAN
For years my homelab ran three daemons for what is really one job: giving every client on the segment a working resolver, a usable address, and a time source. BIND held DNS, ISC DHCP handed out leases, and chrony kept clocks honest. Three config files, three restarts, three log files to grep whenever a laptop could not resolve a name. On a small office network that is a lot of moving parts for a single responsibility.
dnsmasq collapses it into one process and one config file. It listens on UDP/TCP 53 for DNS, UDP 67 for DHCP, and it advertises itself as an NTP server on UDP 123 when you set dhcp-option=option:ntp-server. One restart covers every change, and the log is one journal. That is the reason I standardised on it: one box, one process, one file to reason about at 2 a.m.
The 60-second decision
Before I committed I ran the options side by side in my head.
- systemd-resolved is already on every systemd box, but it is a stub resolver, not a LAN server. It does not hand out DHCP leases and it does not do NTP. Pressing it into service as the network DNS means fighting its stub listener, which is precisely the trap in the next section.
- Unbound is a genuinely good validating recursive resolver, and it is what I reach for when I need DNSSEC on the upstream side. But it is DNS only. I still need a separate DHCP server and a separate NTP daemon.
- AdGuard Home is the pick if you want blocking and a web UI. It is a heavier install though, and the DHCP module is not the reason I would choose it; it is a filtering product first.
- dnsmasq is DNS + DHCP + NTP in one binary, it reads
/etc/hosts, it is tiny, and it is in the base repos of every distro I run.
dnsmasq is not the best at any single one of those jobs, but it is the only one that does all three cleanly. In a homelab, fewer daemons beats a marginally better resolver.
The port-conflict trap
The first time I deployed dnsmasq on a Ubuntu server the unit reported active and then nothing happened. No error on the console, no line in the journal, no listening socket. dnsmasq had not crashed. It had silently failed to bind, because systemd-resolved was already holding port 53.
How I found it:
The output:
There it was. systemd-resolved owned the port on 127.0.0.53, and dnsmasq had quietly skipped the address it could not take. The service did not even mark itself failed, which is what makes this trap nasty: the process starts, claims it is fine, and serves nothing.
I have resolved it three different ways across different boxes, and all of them work:
- Kill the stub listener. Set
DNSStubListener=noin/etc/systemd/resolved.conf, then point systemd-resolved at the LAN address as its upstream. Best on a client that still wants its own resolver. - Mask it entirely.
systemctl stop systemd-resolved,systemctl mask systemd-resolved, and replace the/etc/resolv.confsymlink so it points at the dnsmasq address. Best on a dedicated DNS box. - Move dnsmasq off 127.0.0.53. Give it a second listen address or run it on the LAN interface only and leave the stub alone.
On my gateway I use option 2. On clients I keep systemd-resolved and just point it upstream at dnsmasq.
The production config template
This is the file I run on my homelab gateway, a box with 192.168.50.1 on the LAN. I trimmed the comments down to what matters.
# /etc/dnsmasq.conf
# --- listening ---
listen-address=192.168.50.1
bind-interfaces
port=53
# --- never forward short or private names ---
domain-needed
bogus-priv
no-hosts
# --- upstreams (we own this choice) ---
no-resolv
server=1.1.1.1
server=9.9.9.9
server=8.8.8.8
# --- local zone and static maps ---
local=/lan/
domain=lan
expand-hosts
addn-hosts=/etc/dnsmasq.hosts
# --- DHCP ---
dhcp-range=192.168.50.100,192.168.50.200,12h
dhcp-option=option:router,192.168.50.1
dhcp-option=option:dns-server,192.168.50.1
dhcp-option=option:domain-name,lan
dhcp-authoritative
# --- TFTP and NTP ---
enable-tftp
tftp-root=/srv/tftp
dhcp-option=option:ntp-server,192.168.50.1
# --- cache, logging ---
cache-size=2000
no-negcache
log-queries
log-dhcp
Lines worth calling out:
bind-interfacesis not optional on a gateway. Without it dnsmasq binds every interface, and on a box with a VPN or a second NIC it starts answering queries you never intended to answer.no-resolvremoves/etc/resolv.conffrom the picture entirely, which matters because on a systemd box that file is a symlink into the systemd-resolved stub. Withoutno-resolvI would be forwarding to my own stub — a loop.addn-hostskeeps my static entries out of/etc/hostsand in one file I can manage with Ansible.dhcp-authoritativedeclares this the only DHCP server on the segment, so it can reclaim addresses and answer clients already leased elsewhere.
The static host file:
# /etc/dnsmasq.hosts
192.168.50.10 nas.lan nas
192.168.50.11 printer.lan printer
192.168.50.12 pi.lan pi
192.168.50.20 media.lan media
With expand-hosts and domain=lan, a short query for nas expands to nas.lan automatically. That is the behaviour I want for the few hosts I always reach by first name.
Upstream strategy and why no-negcache
For a NAT'd homelab the upstream question is simple: I want speed and no surprises. I run three public resolvers and let dnsmasq try them in order, failing over when one is slow. I do not front this with Unbound — another recursive resolver would be another daemon, and I already decided dnsmasq is enough for this network. When I need DNSSEC I swap in a single server= line and accept the extra latency.
Now no-negcache, which is easy to overlook and easy to regret. In a NAT'd network the upstream is flaky, and a bad upstream can return a SERVFAIL or a spurious NXDOMAIN for a record that actually exists. By default dnsmasq caches negative answers for the negative TTL. no-negcache disables that, so a record that was temporarily NXDOMAIN is re-queried immediately instead of being served stale from cache. I hit a concrete version of this in Incident 2. For a homelab where a host can be down for a minute and come back, negative caching is the wrong default.
Validating the deployment
After every change I run the same four queries. I point dig straight at dnsmasq so I am testing my server, not the system resolver.
# static host, short name
dig @192.168.50.1 nas +short
# -> 192.168.50.10
# static host, fully qualified
dig @192.168.50.1 nas.lan +short
# -> 192.168.50.10
# reverse zone
dig @192.168.50.1 -x 192.168.50.10 +short
# -> nas.lan.
# upstream passthrough
dig @192.168.50.1 example.com +short
# -> 93.184.216.34
host is lighter when I am on a client without dig:
From a client I confirm the running resolver two ways, depending on distro:
Both should list 192.168.50.1, and on a systemd-resolved client resolvectl status should show the LAN address as the upstream rather than 127.0.0.53.
DHCP and static leases
The DHCP half is where I nail infrastructure down so DNS and DHCP agree. dhcp-host takes the MAC, the name, and the address, and dnsmasq injects that name into DNS when expand-hosts is on.
The router option is critical: dhcp-option=option:router,192.168.50.1 tells every client where its gateway is. I also push the DNS server so clients do not silently fall back to a public resolver behind my back.
Leases live in /var/lib/misc/dnsmasq.leases on Debian and /var/lib/dnsmasq/dnsmasq.leases on Arch. Each line is:
Checking the lease file is the fastest way to see who is on the network and whether an address is still held. If a lease is in the file but the host is gone, the address stays reserved until the expiry passes, which is why the 12h lease time on dhcp-range matters.
Three incidents I actually hit
Incident 1: silent failure on port 53
This is the trap from earlier. dnsmasq reported success, clients got no DNS, and the only clue was ss -lunp | grep ':53 ' showing systemd-resolved on 127.0.0.53. Fix: stop and mask systemd-resolved on the dedicated box, repoint the resolv.conf, restart dnsmasq, then watch the journal for the bind line. It took me longer than it should have because nothing screamed.
Incident 2: stale negative cache after an upstream change
I moved from one upstream to a new pair, and a host that had been briefly NXDOMAIN kept failing even after it came back. The old negative answer was being served from cache for the full negative TTL. Adding no-negcache and restarting made the record resolve on the very first query. Lesson: in a NAT'd network, never let a negative answer outlive the outage that caused it.
Incident 3: overlapping DHCP ranges and a rogue server
A second config on another box had a dhcp-range that overlapped my primary, and the router's built-in DHCP was still enabled. Clients pulled two different addresses and occasionally an APIPA 169.254.169.254 when a lease expired at the wrong time. The fix was non-overlapping ranges, dhcp-authoritative, and disabling the router DHCP. From then on the log lease output matched reality.
15-command cheat sheet
systemctl status dnsmasq # is the daemon up
systemctl restart dnsmasq # apply config changes
systemctl reload dnsmasq # reload without dropping leases
dnsmasq --test # syntax-check the config
dnsmasq --test -C /path/to/conf # check a specific config file
ss -lunp | grep ':53 ' # who holds DNS
ss -lunp | grep ':67 ' # who holds DHCP
cat /var/lib/misc/dnsmasq.leases # current leases
dig @192.168.50.1 nas +short # static host
dig @192.168.50.1 -x 192.168.50.10 # reverse lookup
dig @192.168.50.1 example.com # upstream passthrough
host printer 192.168.50.1 # lightweight check
resolvectl status # client-side resolver state
journalctl -u dnsmasq -f # live logs
pkill -HUP dnsmasq # manual reload if needed
FAQ
Why does dnsmasq fail silently when systemd-resolved is running?
systemd-resolved binds 127.0.0.53:53 by default, so dnsmasq cannot grab the port. Check with ss -lunp | grep ':53 ', then stop systemd-resolved or point dnsmasq at a different listen address.
Can dnsmasq do DNS without DHCP?
Yes. Remove the dhcp-range section and it becomes a pure DNS forwarder. You can also keep DHCP but split the gateway role onto a separate box for easier rollback.
What does no-negcache actually do and when do I need it? It stops dnsmasq caching negative answers, so a record that was temporarily NXDOMAIN is re-queried immediately. In a NAT'd home network with flaky upstreams, it prevents stale negative results.
How do I validate a dnsmasq deployment? Run dig against the dnsmasq IP for a static host, a short name (expand-hosts), a reverse lookup, and an upstream name. Each should return the expected record; a missing one points to a config or upstream issue.
Where do leases live and how do I check expiry?
On Debian they are in /var/lib/misc/dnsmasq.leases, on Arch in /var/lib/dnsmasq/dnsmasq.leases. Each line starts with the expiry epoch, so I can compare it to date +%s to see how much time a lease has left.