3 min read

Running a real local DNS server next to systemd-resolved

Running a real local DNS server next to systemd-resolved
💡
Post in English, because of the Google-juice :) Can never find the answer myself, so hope this post helps people looking for an answer.

I keep forgetting this, and every time I need it again I end up searching for the same thing. It is surprisingly hard to find the exact correct answer, so I am documenting it here. Mostly for future me, but maybe also for future you.

The situation: you have a modern Linux system with systemd-resolved installed, but you want to run a proper local DNS server as well. listening locally on 127.0.0.1:53.

And then systemd-resolved enters the room and creates hours of debugging frustrations.

By default, systemd-resolved runs its own local DNS stub listener on loopback, typically at 127.0.0.53:53. That is useful for many systems, but it can get in the way when you want your own DNS daemon to own local port 53.

The naive fix is to disable systemd-resolved, fight NetworkManager, rewrite /etc/resolv.conf, maybe even chattr +i it to make sure nobody touches it, and slowly build a nightmare for future-you.

Do not do that. Use the system instead of fighting it.

Use systemd-resolved, but point it at your local DNS server and turn off only its stub listener.

Create a drop-in such as:

# /etc/systemd/resolved.conf.d/90-local-dns.conf
[Resolve]
DNS=127.0.0.1
Domains=~.
DNSStubListener=no

Then restart resolved:

sudo systemctl restart systemd-resolved

That is the whole trick.

What this does

DNS=127.0.0.1 tells systemd-resolved to use your local DNS server as its system DNS server.

DNSStubListener=no disables the built-in stub listener on 127.0.0.53:53, so your own DNS server can bind to port 53 without fighting resolved.

This setup does not throw away systemd's DNS routing model. It still lets systemd-resolved be the central resolver. Applications that use glibc lookups via NSS still work. Applications that talk to systemd-resolved over D-Bus or Varlink still work. Tools that inspect resolved's state still see a coherent DNS setup.

And split DNS can still work.

For example, Tailscale's MagicDNS integrates with systemd-resolved by adding DNS routing for the tailnet. That route is more specific than ~., so those names can still go where Tailscale wants them to go, while ordinary public DNS goes through your local resolver.

A note about /etc/resolv.conf

If your distribution uses nss-resolve, normal libc lookups such as getent hosts ... go through systemd-resolved and keep the split-DNS behaviour.

Some older tools bypass NSS and read /etc/resolv.conf directly. If you disabled the stub listener, make sure /etc/resolv.conf is not still pointing only at 127.0.0.53. Either point it at systemd's non-stub file, usually /run/systemd/resolve/resolv.conf, or make sure those direct DNS clients can reach your local DNS server on 127.0.0.1.

The important bit: split DNS needs the query to go through systemd-resolved. A tool that bypasses resolved is also bypassing resolved's routing logic. That is not a bug in this setup; it is just that tool being old-fashioned in the usual inconvenient way.

Make sure glibc actually uses systemd-resolved

One extra thing: install the NSS module for systemd-resolved.

sudo apt install libnss-resolve

That package makes normal glibc lookups go through systemd-resolved via NSS. In other words, applications using getaddrinfo() get the same DNS routing behaviour as tools that talk to resolved over D-Bus or Varlink.

This matters when other software plugs into systemd-resolved. Tailscale MagicDNS is the obvious example: Tailscale automatically adds the right split-DNS route to resolved, but your normal applications only benefit from that route if their libc lookups actually go through resolved.

Without libnss-resolve, some systems still appear to work for simple public DNS while silently bypassing the interesting part: resolved's per-link and split-DNS routing.

Quick checks

After restarting things, check resolved:

resolvectl status

You should see 127.0.0.1 as a DNS server and ~. as a routing domain.

Then test both the resolved path and the normal libc path:

resolvectl query example.com
getent hosts example.com

If both work, you have the useful version of this setup: a real local DNS server, without throwing away systemd-resolved's integration with the rest of the system.

Small config. Much less swearing next time.

References