DEV Community

Cover image for Stop Leaking DNS Queries on Linux: Practical systemd-resolved with DNS-over-TLS
Lyra
Lyra

Posted on

Stop Leaking DNS Queries on Linux: Practical systemd-resolved with DNS-over-TLS

Plain DNS still leaves most of your browsing metadata on the wire.

Every apt update, container pull, SSH hostname lookup, and browser tab starts with a DNS query. On a typical Linux host that query still goes out as cleartext UDP/53 to whatever DHCP handed you — often your ISP, a coffee-shop gateway, or a poorly hardened router. Anyone on path can see the names. Anyone who can spoof replies can steer you somewhere else.

You do not need a separate DNS proxy container or a third-party client daemon to fix this. systemd-resolved already speaks DNS-over-TLS (DoT) on port 853. With a small drop-in and a correct /etc/resolv.conf stub link, you can encrypt outbound DNS system-wide and keep the usual Linux tooling (getent, browsers, Go binaries, curl) working.

This guide is a practical, copy-paste setup for Debian/Ubuntu and other systemd hosts. It sticks to documented options from resolved.conf(5) and resolvectl(1).

What you get

  • Encrypted upstream DNS (DoT) instead of cleartext port 53
  • A local stub listener on 127.0.0.53 so apps keep using standard glibc resolution
  • Optional DNSSEC validation once your upstream actually supports it
  • Per-link overrides when a VPN or split-DNS domain must stay local
  • Verifiable status with resolvectl and a simple packet check on port 853

Prerequisites

  • A host running systemd-resolved (default on current Ubuntu; available on Debian and most modern systemd distros)
  • Root or sudo
  • Outbound TCP/853 allowed to your chosen resolver
  • A resolver that supports DoT (examples below: Quad9, Cloudflare)

Check that the service is present:

systemctl status systemd-resolved --no-pager
resolvectl status
Enter fullscreen mode Exit fullscreen mode

If the unit is missing on Debian, install the package:

sudo apt update
sudo apt install -y systemd-resolved
sudo systemctl enable --now systemd-resolved
Enter fullscreen mode Exit fullscreen mode

Step 1 — Use the stub resolv.conf (critical)

systemd-resolved exposes a local DNS stub on 127.0.0.53. Applications that read /etc/resolv.conf directly only benefit if that file points at the stub.

Recommended layout (stub mode):

# See what you have now
ls -l /etc/resolv.conf
resolvectl status | sed -n '1,20p'

# Point resolv.conf at the stub managed by systemd-resolved
sudo ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf

# Confirm
ls -l /etc/resolv.conf
cat /etc/resolv.conf
Enter fullscreen mode Exit fullscreen mode

You should see something like:

nameserver 127.0.0.53
options edns0 trust-ad
search .
Enter fullscreen mode Exit fullscreen mode

Notes:

  • The relative target ../run/systemd/resolve/stub-resolv.conf is intentional (relative to /etc/).
  • If /etc/resolv.conf is a static file filled by DHCP or NetworkManager in "foreign" mode, DoT settings in resolved will not help every app.
  • Arch Wiki and the systemd docs both recommend stub mode for full feature propagation.

Step 2 — Drop-in: force DNS-over-TLS

Prefer a drop-in over editing /etc/systemd/resolved.conf directly. Drop-ins survive package updates cleanly.

sudo mkdir -p /etc/systemd/resolved.conf.d
sudo tee /etc/systemd/resolved.conf.d/dns_over_tls.conf >/dev/null <<'EOF'
[Resolve]
# Quad9 recommended (malware blocking + DNSSEC on their side)
# Format: address#SNI_hostname  (SNI is used for TLS cert validation)
DNS=9.9.9.9#dns.quad9.net 149.112.112.112#dns.quad9.net 2620:fe::fe#dns.quad9.net 2620:fe::9#dns.quad9.net

# Require encrypted transport. If the server cannot do DoT, lookups fail closed.
DNSOverTLS=yes

# Route global lookups through the DNS= servers above
Domains=~.

# Optional: validate answers locally when upstream supports DNSSEC properly.
# Start with allow-downgrade if you are unsure about your path; tighten later.
DNSSEC=allow-downgrade

# Keep the local stub on 127.0.0.53
DNSStubListener=yes
EOF
Enter fullscreen mode Exit fullscreen mode

Apply:

sudo systemctl restart systemd-resolved
resolvectl flush-caches
resolvectl status
Enter fullscreen mode Exit fullscreen mode

Why the #hostname suffix matters

resolved.conf(5) documents the full DNS server form:

address:port%ifname#server_name
Enter fullscreen mode Exit fullscreen mode

The #server_name value is Server Name Indication (SNI). With DNSOverTLS=yes, systemd-resolved uses it to validate the upstream TLS certificate. Without a hostname, validation falls back to checking the certificate against the bare IP, which many public resolvers do not present cleanly.

DNSOverTLS modes (do not mix these up)

Value Behavior Risk
no (default) Classic DNS (UDP/TCP 53) Cleartext on path
opportunistic Try DoT, fall back to cleartext Downgrade / MITM possible
yes / true DoT required Lookups fail if upstream cannot speak TLS

For privacy on untrusted networks, prefer yes. Use opportunistic only as a temporary migration step.

Alternative upstream examples

Cloudflare (from the comments shipped in resolved.conf):

[Resolve]
DNS=1.1.1.1#cloudflare-dns.com 1.0.0.1#cloudflare-dns.com 2606:4700:4700::1111#cloudflare-dns.com 2606:4700:4700::1001#cloudflare-dns.com
DNSOverTLS=yes
Domains=~.
Enter fullscreen mode Exit fullscreen mode

Google Public DNS:

[Resolve]
DNS=8.8.8.8#dns.google 8.8.4.4#dns.google 2001:4860:4860::8888#dns.google 2001:4860:4860::8844#dns.google
DNSOverTLS=yes
Domains=~.
Enter fullscreen mode Exit fullscreen mode

Pick a resolver you actually trust. Encryption protects the path; it does not make the resolver itself private.

Step 3 — Verify encryption and resolution

A. Confirm resolved sees DoT

resolvectl status
Enter fullscreen mode Exit fullscreen mode

In the Global section you want protocols showing +DNSOverTLS (or DNSOverTLS enabled) and your configured servers listed with the SNI hostnames.

Also useful:

resolvectl query dev.to
resolvectl statistics
resolvectl show-server-state 2>/dev/null || true
Enter fullscreen mode Exit fullscreen mode

A healthy query line looks like:

-- Data was acquired via local or encrypted transport: yes
Enter fullscreen mode Exit fullscreen mode

B. Confirm traffic moved off port 53

In one terminal, generate lookups:

resolvectl flush-caches
for i in 1 2 3 4 5; do
  resolvectl query example.com >/dev/null
  resolvectl query dev.to >/dev/null
  sleep 0.2
done
Enter fullscreen mode Exit fullscreen mode

In another, watch sockets (pick one):

# ss snapshot while queries run
sudo ss -tpn | grep -E ':853|:53' || true

# Or a short tcpdump (requires privileges)
sudo timeout 8 tcpdump -ni any port 853 -c 20
Enter fullscreen mode Exit fullscreen mode

Expectations:

  • Active connections or packets to upstream :853
  • No burst of cleartext client queries to public resolvers on :53 from this host (local stub traffic to 127.0.0.53:53 is normal)

C. Application-level smoke test

getent hosts dev.to
python3 - <<'PY'
import socket
print(socket.getaddrinfo("example.com", 443)[:2])
PY
curl -I --max-time 10 https://example.com | head -n 5
Enter fullscreen mode Exit fullscreen mode

If only resolvectl works but getent/curl fail, your /etc/resolv.conf stub link is wrong — go back to Step 1.

Step 4 — Split DNS without breaking the LAN or VPN

Homelabs and VPNs often need some names on a private resolver and everything else on encrypted public DNS.

Pattern A — Keep DHCP DNS for a local search domain

If your LAN DNS should answer *.lab.example but global names should use DoT, avoid stomping per-link DNS blindly.

With systemd-networkd, you can set DoT only where appropriate in the .network file:

# /etc/systemd/network/20-lan.network (illustrative)
[Match]
Name=eth0

[Network]
DHCP=yes
# Use the domain from DHCP as a search domain when appropriate
UseDomains=true

# Optional per-link override:
# DNSOverTLS=no
# DNS=192.168.1.1
# Domains=lab.example
Enter fullscreen mode Exit fullscreen mode

Global DoT servers from resolved.conf.d still handle names that do not match more specific route-only/search domains.

With NetworkManager:

# Require DoT on a specific connection (2 = yes)
nmcli connection modify "Wired connection 1" connection.dns-over-tls 2
nmcli connection up "Wired connection 1"
Enter fullscreen mode Exit fullscreen mode

Pattern B — Tailscale / WireGuard split DNS

If a VPN interface publishes its own DNS and route-only domains (common with Tailscale MagicDNS), leave that link alone. resolvectl status will show per-link DNS servers and ~domain route-only entries. Global DNSOverTLS=yes applies to the global/system servers; per-link settings can differ.

Inspect routing of names:

resolvectl domain
resolvectl dns
resolvectl query some-host.on.your.ts.net
Enter fullscreen mode Exit fullscreen mode

Pattern C — Temporary runtime override (no file edit)

# Example: point eth0 at Quad9 with DoT for a quick test
sudo resolvectl dns eth0 9.9.9.9#dns.quad9.net 149.112.112.112#dns.quad9.net
sudo resolvectl dnsovertls eth0 yes
sudo resolvectl domain eth0 '~.'
resolvectl status eth0

# Later, undo runtime changes on that link
sudo resolvectl revert eth0
Enter fullscreen mode Exit fullscreen mode

Runtime changes are excellent for testing. Put the final policy in drop-ins / .network files so it survives reboot.

Step 5 — Optional hardening knobs

Disable LLMNR / mDNS if you do not need them

LLMNR and mDNS are separate from upstream DoT. On servers that do not need local multicast name discovery:

sudo tee /etc/systemd/resolved.conf.d/no-multicast.conf >/dev/null <<'EOF'
[Resolve]
LLMNR=no
MulticastDNS=no
EOF
sudo systemctl restart systemd-resolved
Enter fullscreen mode Exit fullscreen mode

If you use printer discovery or .local hosts via Avahi, do not blindly disable mDNS.

FallbackDNS hygiene

If no DHCP or static DNS is available, resolved can use compiled-in fallback servers. Pin or clear them deliberately:

# Prefer your chosen DoT servers even as fallback
FallbackDNS=9.9.9.9#dns.quad9.net 149.112.112.112#dns.quad9.net

# Or disable fallback entirely (fail closed when no DNS config exists)
# FallbackDNS=
Enter fullscreen mode Exit fullscreen mode

Cache visibility

resolvectl show-cache 2>/dev/null | head
resolvectl flush-caches
Enter fullscreen mode Exit fullscreen mode

Caching is on by default and is a feature, not a bug. Flush after policy changes.

Troubleshooting checklist

Symptom Likely cause Fix
Only some apps resolve /etc/resolv.conf not stub-linked ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
All DNS fails after DNSOverTLS=yes Upstream blocked on TCP/853 or no DoT Open 853/tcp, or test with opportunistic, or switch resolver
Still see port 53 to ISP DNS Per-link DHCP DNS winning without Domains=~. Add Domains=~. for global servers; review resolvectl status
Private VPN names break Global DoT servers stole split-horizon names Keep VPN route-only domains; do not force those names to public DNS
DNSSEC failures Upstream or path does not support DNSSEC cleanly Use DNSSEC=allow-downgrade or no until path is clean
Stale answers after cutover Local cache resolvectl flush-caches and resolvectl reset-server-features

Debug logging when needed:

sudo resolvectl log-level debug
# reproduce a bad lookup
resolvectl query broken.example
journalctl -u systemd-resolved -e --no-pager | tail -n 80
# return to normal
sudo resolvectl log-level info
Enter fullscreen mode Exit fullscreen mode

Complete minimal playbook (copy/paste)

#!/usr/bin/env bash
set -euo pipefail

sudo systemctl enable --now systemd-resolved

# Stub resolv.conf so glibc clients hit 127.0.0.53
sudo ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf

sudo mkdir -p /etc/systemd/resolved.conf.d
sudo tee /etc/systemd/resolved.conf.d/dns_over_tls.conf >/dev/null <<'EOF'
[Resolve]
DNS=9.9.9.9#dns.quad9.net 149.112.112.112#dns.quad9.net 2620:fe::fe#dns.quad9.net 2620:fe::9#dns.quad9.net
DNSOverTLS=yes
Domains=~.
DNSSEC=allow-downgrade
DNSStubListener=yes
EOF

sudo systemctl restart systemd-resolved
resolvectl flush-caches

echo "=== status ==="
resolvectl status | sed -n '1,40p'
echo "=== query ==="
resolvectl query example.com
echo "=== resolv.conf ==="
ls -l /etc/resolv.conf
cat /etc/resolv.conf
Enter fullscreen mode Exit fullscreen mode

What this does not replace

  • DoT is not a VPN. It encrypts DNS, not your general traffic.
  • DoT is not DNS-over-HTTPS (DoH). Browsers may still do their own DoH unless you disable it; system resolver policy and browser policy are separate.
  • A trusted path still needs a trusted resolver. Encrypting queries to a resolver that logs everything is a different threat model.
  • Local malicious network names (LLMNR/NBNS spoofing on LAN) are a separate problem from upstream encryption.

Wrap-up

If your Linux hosts still send cleartext DNS, you are publishing a high-signal metadata stream for free. systemd-resolved already has the fix:

  1. Stub-link /etc/resolv.conf
  2. Drop in DNS= servers with #sni hostnames
  3. Set DNSOverTLS=yes
  4. Verify with resolvectl and a port-853 check
  5. Keep split-DNS/VPN domains on their own links

That is a small, reversible change with an outsized privacy and integrity win — no extra daemon required.

Sources and references

  • resolved.conf(5)DNS=, DNSOverTLS=, DNSSEC=, Domains=, stub listener options (systemd man pages)
  • resolvectl(1)status, query, dns, dnsovertls, flush-caches, show-server-state
  • systemd-resolved.service(8) — stub/static/uplink/foreign /etc/resolv.conf modes
  • Arch Wiki: systemd-resolved — practical stub mode and DoT examples
  • Quad9 service addresses — DoT endpoint tls://dns.quad9.net and IPs 9.9.9.9 / 149.112.112.112
  • RFC 7858 — DNS over TLS specification

If you run this on a laptop and a homelab box, start with the laptop on untrusted Wi-Fi. That is usually where cleartext DNS hurts first.

Top comments (0)