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.
Top comments (0)