Skip to content
Go back

systemd-resolved: The DNS Resolver You're Already Using Wrong

By SumGuy 10 min read
systemd-resolved: The DNS Resolver You're Already Using Wrong
Contents

Your /etc/resolv.conf Is a Liar (and That’s Fine)

You cat /etc/resolv.conf, see nameserver 127.0.0.53, and immediately think something’s broken. Maybe a previous sysadmin hacked it. Maybe it’s a VM artifact. Maybe you should just replace it with 8.8.8.8 and call it a day.

Don’t. That file is a symlink pointing to a stub resolver that’s quietly doing more useful work than most people ever configure it for. systemd-resolved has been the default DNS resolver on Ubuntu since 16.10, Fedora since forever, and it’s baked into any modern distro running systemd — which is basically all of them.

Here’s the thing: most people treat it like a middleman to disable. That’s backwards. Used right, it’s one of the better pieces of the systemd puzzle.


What systemd-resolved Actually Is

systemd-resolved is a caching stub resolver that runs as a system daemon (systemd-resolved.service). It listens on 127.0.0.53:53 and acts as the local DNS endpoint for everything on your machine.

That’s the stub part. “Stub” means it doesn’t actually resolve anything recursively — it forwards upstream to real DNS servers (your router, your ISP, Cloudflare, whatever) and caches the results locally.

What makes it interesting is everything layered on top:

The stub at 127.0.0.53 handles all of this transparently. Your application asks for example.com, the stub figures out which DNS server to ask based on what network interface you’re on, validates the response if DNSSEC is configured, and hands the answer back.


When systemd-resolved is running, /etc/resolv.conf is a symlink — usually to one of these:

/run/systemd/resolve/stub-resolv.conf → nameserver 127.0.0.53 (recommended)
/run/systemd/resolve/resolv.conf → actual upstream DNS servers (bypass mode)

The stub-resolv.conf version is what you want. It points everything at the stub, which handles routing. The resolv.conf version bypasses the stub entirely and talks directly to upstream — you lose caching, split DNS, and DNSSEC.

If your /etc/resolv.conf isn’t a symlink at all — maybe something wrote a static file over it — you can fix it:

Terminal window
sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf

Some tools (Docker, NetworkManager, VPN clients) will overwrite this. That’s a common source of “why did my DNS break after I connected to the VPN” complaints.


resolvectl: Your New Best Friend

resolvectl is the CLI for interacting with systemd-resolved. If you’ve been using systemd-resolve (the old name), it still works — it’s just an alias.

Check Current Status

Terminal window
resolvectl status
Global
Protocols: -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
resolv.conf mode: stub
DNS Servers: 192.168.1.1
DNS Domain: ~.
Link 2 (eth0)
Current Scopes: DNS
Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
DNS Servers: 192.168.1.1

Read this output carefully. It tells you:

Query DNS Directly

Terminal window
resolvectl query example.com
resolvectl query -t MX gmail.com
resolvectl query -t TXT _dmarc.example.com

This bypasses your local applications and asks systemd-resolved directly. Useful for confirming the resolver sees what you expect.

Flush the Cache

Terminal window
resolvectl flush-caches

Simple. Kills the cache. Use this when you’ve updated DNS records and dig still shows the old answer.

Terminal window
resolvectl dns eth0 1.1.1.1 1.0.0.1
resolvectl domain eth0 ~example.com

The ~ prefix on the domain means “route DNS queries for this domain over this link.” This is how split DNS works — more on that below.

Check Statistics

Terminal window
resolvectl statistics
Transactions
Current Transactions: 0
Total Transactions: 1423
Cache
Current Cache Size: 87
Cache Hits: 934
Cache Misses: 489

If your cache hit rate looks terrible, you’re either flushing too often or TTLs are very short (looking at you, AWS).


Split DNS: The Reason VPNs Break Your DNS

Split DNS is the killer feature most people don’t know they’re getting. The idea: route DNS for specific domains through specific interfaces.

When you connect to a corporate VPN, the VPN pushes a DNS server for corp.example.com. systemd-resolved picks this up automatically (if your VPN client is well-behaved) and routes queries for corp.example.com through the VPN interface, while everything else continues going to your normal DNS.

You can verify this with resolvectl status — look for the per-link DNS configuration and domain routing rules.

For manual split DNS (say, you’re managing a homelab with a separate DNS for .home.arpa):

Terminal window
resolvectl dns eth0 10.0.0.53
resolvectl domain eth0 ~home.arpa

Now all .home.arpa queries go to your local DNS server on that link. Queries for anything else still go to your regular upstream.


Configuring systemd-resolved Persistently

The main config file is /etc/systemd/resolved.conf. Drop-in files go in /etc/systemd/resolved.conf.d/.

/etc/systemd/resolved.conf.d/dns.conf
[Resolve]
DNS=1.1.1.1 1.0.0.1
FallbackDNS=9.9.9.9 149.112.112.112
Domains=~.
DNSSEC=allow-downgrade
DNSOverTLS=opportunistic
Cache=yes

Reload after editing:

Terminal window
sudo systemctl restart systemd-resolved

Key options:

DNS over TLS Setup

DoT encrypts your DNS queries so your ISP can’t log every hostname you visit. With Cloudflare:

/etc/systemd/resolved.conf.d/dot.conf
[Resolve]
DNS=1.1.1.1#cloudflare-dns.com 1.0.0.1#cloudflare-dns.com
DNSOverTLS=yes

The #hostname suffix is required for TLS certificate validation — without it, systemd-resolved can’t verify it’s talking to the right server.

Verify it’s working:

Terminal window
resolvectl status | grep DNSOverTLS

systemd-networkd .network Drop-ins

If you’re using systemd-networkd (common on servers, minimal installs, containers), you configure per-link DNS in .network files under /etc/systemd/network/.

/etc/systemd/network/10-eth0.network
[Match]
Name=eth0
[Network]
DHCP=yes
DNS=10.0.0.53
Domains=~corp.internal
[DHCP]
UseDNS=false

UseDNS=false prevents DHCP from overwriting your DNS setting. Critical if your router pushes garbage DNS and you want to override it.

For static DNS without DHCP:

/etc/systemd/network/10-eth0.network
[Match]
Name=eth0
[Network]
Address=192.168.1.100/24
Gateway=192.168.1.1
DNS=1.1.1.1 1.0.0.1

Reload networkd after changes:

Terminal window
sudo networkctl reload
resolvectl status

NetworkManager Integration

Most desktop distros use NetworkManager, not systemd-networkd. NM has its own DNS plugin architecture, and the interaction with systemd-resolved trips people up.

Check how NM is handling DNS:

Terminal window
grep -r dns= /etc/NetworkManager/

You want to see dns=systemd-resolved in /etc/NetworkManager/NetworkManager.conf or a drop-in:

/etc/NetworkManager/conf.d/systemd-resolved.conf
[main]
dns=systemd-resolved

With this set, NM pushes per-connection DNS config into systemd-resolved automatically. Connecting to a VPN? NM tells resolved about the VPN’s DNS servers and domain routing. Disconnecting? NM removes them. It’s clean.

Without this config, NM manages /etc/resolv.conf itself and writes directly over the symlink. Then systemd-resolved is running but ignored. Your queries bypass the stub, you lose caching, split DNS doesn’t work, and you get confused when things break inconsistently.


Common Breakage Patterns

Tailscale MagicDNS

Tailscale’s MagicDNS pushes a 100.100.100.100 DNS server and registers your tailnet hostnames. When it’s working right, systemd-resolved routes *.ts.net queries to Tailscale’s resolver while everything else goes to your normal DNS.

When it breaks, usually it’s because Tailscale set itself as the default route DNS (overriding the ~. domain) and now all your DNS is going through Tailscale. Check:

Terminal window
resolvectl status | grep -A5 tailscale

If you see ~. on the Tailscale interface, that’s why. Either configure Tailscale to not be the default route, or set a lower priority explicitly.

Docker Bridge DNS

Docker creates a docker0 bridge interface. By default, containers use Docker’s internal DNS (usually 127.0.0.11 inside the container network). But on the host side, if systemd-resolved is trying to route queries for container hostnames and the Docker interface shows up in resolvectl status, you can get weird resolution failures.

The fix: make sure Docker’s DNS is isolated. In /etc/docker/daemon.json:

{
"dns": ["1.1.1.1", "8.8.8.8"]
}

This explicitly sets DNS for containers and stops Docker from interfering with the host resolver.

If you’re running dnsmasq alongside Docker and systemd-resolved, you’re in for a bad time. Pick one path and stick with it.

Corporate VPN with Custom CA

If your corporate DNS requires queries to go through a VPN that uses a custom certificate authority, DoT validation will fail because the CA isn’t in your system trust store. You’ll see errors like:

DNSSEC validation failed for question corp.example.com

Options:

  1. Add the corporate CA cert to your system trust store
  2. Use DNSSEC=allow-downgrade so it degrades gracefully instead of hard-failing
  3. Set DNSSEC=no for that specific link (add DNSSEC=no under [Network] in the networkd .network file)

LLMNR and mDNS

systemd-resolved handles two local network discovery protocols:

Out of the box, systemd’s per-link default is LLMNR yes and mDNS no — so LLMNR is usually on (and yes, it’s a known poisoning attack vector you may want off), while mDNS needs turning on. If you need .local resolution:

/etc/systemd/resolved.conf.d/mdns.conf
[Resolve]
MulticastDNS=resolve
LLMNR=no

resolve means “answer queries but don’t register your own hostname.” Safer than yes, which registers you as a responder.


When to Disable systemd-resolved

Honestly? Rarely. The common “just disable it” advice usually trades one problem (confusion about the stub) for a worse one (no caching, no split DNS, broken VPN routing).

If you genuinely need to disable it — maybe you’re running dnsmasq or unbound as a proper local resolver — do it cleanly:

Terminal window
sudo systemctl disable --now systemd-resolved
sudo rm /etc/resolv.conf
echo "nameserver 127.0.0.1" | sudo tee /etc/resolv.conf

Then configure your replacement resolver to listen on 127.0.0.1:53.

If your problem is just DNSSEC validation breaking on a flaky upstream:

/etc/systemd/resolved.conf.d/no-dnssec.conf
[Resolve]
DNSSEC=no

If your problem is a VPN clobbering all DNS:

Terminal window
resolvectl default-route eth0 true
resolvectl default-route tun0 false

There’s almost always a scalpel. You don’t need the sledgehammer.


Quick Reference

TaskCommand
Check current DNS configresolvectl status
Query a hostnameresolvectl query hostname
Flush DNS cacheresolvectl flush-caches
Set per-link DNSresolvectl dns eth0 1.1.1.1
Set per-link domain routingresolvectl domain eth0 ~example.com
Set default route interfaceresolvectl default-route eth0 true
Check cache statsresolvectl statistics
Restart resolvedsudo systemctl restart systemd-resolved

The stub at 127.0.0.53 isn’t a mistake. It’s a feature. Once you understand what’s sitting behind it — the per-link routing, the split DNS, the DoT support, the DNSSEC validation — you’ll stop fighting it and start using it.

Your 2 AM self, debugging VPN DNS failures by staring at a static /etc/resolv.conf, will thank you.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Firefly III vs Actual vs Fava (Beancount)

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts