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 cle
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.53so 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
resolvectland 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
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
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
You should see something like:
nameserver 127.0.0.53
options edns0 trust-ad
search .
Notes:
- The relative target
../run/systemd/resolve/stub-resolv.confis intentional (relative to/etc/). - If
/etc/resolv.confis 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
Apply:
sudo systemctl restart systemd-resolved
resolvectl flush-caches
resolvectl status
Why the #hostname suffix matters
resolved.conf(5) documents the full DNS server form:
address:port%ifname#server_name
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=~.
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=~.
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
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
A healthy query line looks like:
-- Data was acquired via local or encrypted transport: yes
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
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
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:53is 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
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
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"
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
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
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
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=
Cache visibility
resolvectl show-cache 2>/dev/null | head
resolvectl flush-caches
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
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
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:
- Stub-link
/etc/resolv.conf - Drop in
DNS=servers with#snihostnames - Set
DNSOverTLS=yes - Verify with
resolvectland a port-853 check - 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.confmodes - Arch Wiki: systemd-resolved — practical stub mode and DoT examples
-
Quad9 service addresses — DoT endpoint
tls://dns.quad9.netand IPs9.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.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.