Linux Nameservers & DNS Resolution: The Practical Guide
TL;DR: DNS on a modern desktop Linux isn't one file — it's a stack: NSS order, /etc/hosts, the /etc/resolv.conf symlink, and a resolver daemon (systemd-resolved, NetworkManager, or netplan) that keeps rewriting it. You'll learn the resolution order, how to take control with resolvectl, per-link nameservers, the chattr +i pin trick, a full dig/getent troubleshooting battery, and why a trusted recursive resolver (1.1.1.1, 9.9.9.9) plus DNS-over-TLS is a security decision, not a preference. You'll need any mainstream distro — Ubuntu/Debian, Fedora, or Arch — and a terminal.
It was 2 AM, the VPN was up, and every request died with Temporary failure in name resolution. curl, apt, ping — all dead, while the tunnel itself looked healthy. I spent an hour blaming the VPN. The real culprit was a 300-byte file called /etc/resolv.conf — or rather, what was supposed to be in it, before something rewrote it behind my back.
DNS is the protocol you forget until it breaks — and the one an attacker touches first. Poison a resolver and your browser's bank.example.com quietly becomes their server. Accept a rogue DHCP offer on café WiFi and every domain you visit gets logged by someone you never chose. For a security-minded Linux user, knowing where your nameservers come from — and taking control of them — isn't admin trivia. It's hygiene.
⚠ Defensive framing: everything below is about taking control of your own machine's resolution. Point dig @ at other people's resolvers only on networks you're authorized to test — your lab, your CTF box, your own infra.
How DNS Resolution Actually Happens on Linux
Before touching configs, know the order of operations. When an app calls getaddrinfo() (curl, browsers, apt all do), the glibc resolver hands the query to NSS (Name Service Switch), configured in /etc/nsswitch.conf. The hosts: line decides what gets consulted and in what order:
# Which sources does the system consult, and in what order?
grep '^hosts' /etc/nsswitch.conf
# Typical output:
# hosts: files mdns4_minimal [NOTFOUND=return] dns
# (on systemd-resolved systems the 'dns' slot is often 'resolve [!UNAVAIL=return] dns')
Read that line left to right: first files (a static /etc/hosts match wins instantly — no network), then the resolver (the dns source, which reads /etc/resolv.conf and queries the nameservers listed there in order). On systemd-resolved distros, the resolve module sends queries to the local daemon instead, which then does its own per-link routing. If nothing matches anywhere, you get the infamous "Temporary failure in name resolution" — usually because the nameserver list is empty or unreachable.
| Stage | Source | What wins |
|---|---|---|
| NSS order | /etc/nsswitch.conf | files beats dns — order is policy |
| Static mapping | /etc/hosts | Wins instantly over DNS if listed first (and overrides it, which is why attackers love it) |
| Resolver list | /etc/resolv.conf | First nameserver is tried first, next on timeout |
| Daemon routing | systemd-resolved / NetworkManager / netplan | Writes and rewrites /etc/resolv.conf for you |
/etc/resolv.conf: The File Everyone Edits (And Everyone Overwrites)
The classic format is three directives, and it hasn't changed in decades:
# Classic /etc/resolv.conf
nameserver 1.1.1.1 # up to 3 traditionally honored; queried in order
nameserver 9.9.9.9
search example.com lab.local # search list appended to bare hostnames
options edns0 trust-ad # EDNS, and trust the AD bit for DNSSEC
Simple — except that on modern distros /etc/resolv.conf is rarely a real file anymore. It's a symlink, and something regenerates its target on every network event. Check yours:
ls -l /etc/resolv.conf
readlink -f /etc/resolv.conf
# Ubuntu/Debian + systemd-resolved:
# /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf
# (which contains: nameserver 127.0.0.53 <- the local stub, not a real server!)
#
# NetworkManager-managed systems:
# /etc/resolv.conf -> /run/NetworkManager/resolv.conf
#
# Older Debian with resolvconf:
# /etc/resolv.conf -> /etc/resolvconf/run/resolv.conf
That 127.0.0.53 is the crux of half the confusion on Ubuntu/Debian: it's systemd-resolved's local stub resolver, not a real upstream server. Your apps talk to the stub; the stub talks to the actual per-link servers and caches answers. This is a feature — one cache, per-interface routing, DNSSEC in one place — but it means editing /etc/resolv.conf by hand does almost nothing. You have to configure the daemon that owns it.
systemd-resolved: Meet resolvectl
First, see what's actually in effect — global settings and per-link servers:
resolvectl status
# Global
# Protocols: +LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
# resolv.conf mode: stub <- /etc/resolv.conf points at 127.0.0.53
# Current DNS Server: 1.1.1.1
# DNS Servers: 1.1.1.1 9.9.9.9
#
# Link 2 (wlp2s0)
# Current Scopes: DNS
# Protocols: +DefaultRoute +LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
# Current DNS Server: 192.168.1.1 <- DHCP handed this to the WiFi link
# DNS Servers: 192.168.1.1
Notice the split: Global lists system DNS, Link 2 (wlp2s0) shows what DHCP pushed per-interface — and that's what wins for normal lookups on that link. That's the moment I stop being surprised why my hand-edited resolv.conf "didn't take." To override a specific link right now:
# Set per-link DNS servers for the WiFi interface (space-separated)
sudo resolvectl dns wlp2s0 1.1.1.1 9.9.9.9
# Route a whole domain to a specific server: ~example.com -> this link's DNS
sudo resolvectl domain wlp2s0 "~example.com"
# Prove it works
resolvectl query example.com
# Undo all per-link overrides
sudo resolvectl revert wlp2s0
Gotcha I hit in the lab: resolvectl dns changes are runtime-only. Reboot, reconnect, or DHCP renewal and they're gone. You'll also find old tutorials using systemd-resolve --interface wlp2s0 --set-dns 1.1.1.1 --set-domain example.com — that's the deprecated predecessor; on Ubuntu 22.04+ the systemd-resolve binary may not even exist (command not found). The modern form is the positional resolvectl dns <link> <server> you see above.
Global DNS: /etc/systemd/resolved.conf
For a persistent, machine-wide configuration, edit /etc/systemd/resolved.conf — or better, a drop-in, so package upgrades can't clobber your settings:
sudo mkdir -p /etc/systemd/resolved.conf.d
sudo tee /etc/systemd/resolved.conf.d/dns.conf > /dev/null <<'EOF'
[Resolve]
# System-wide upstreams. The #hostname form enables SNI + cert validation for DoT.
DNS=1.1.1.1#cloudflare-dns.com 9.9.9.9#dns.quad9.net
FallbackDNS=8.8.8.8#dns.google
# Route EVERY query through the configured servers (not just specific domains)
Domains=~.
# Encrypt the upstream channel (requires servers that support TLS on port 853)
DNSOverTLS=yes
DNSSEC=allow-downgrade
EOF
sudo systemctl restart systemd-resolved
Key lines, decoded: DNS= sets the system-wide servers; the ip#hostname syntax tells systemd-resolved to validate the server's TLS certificate against that name and enables SNI (required if you set DNSOverTLS=yes — with opportunistic, encryption is attempted and silently skipped if unsupported, which is a documented downgrade risk). Domains=~. is the "route everything" directive — without it, per-link DHCP servers still get used for most queries. And DNSSEC=allow-downgrade validates DNSSEC signatures when the server supports it, instead of hard-failing.
Verify it took with resolvectl status — Current DNS Server: 1.1.1.1#cloudflare-dns.com and DNSOverTLS flipped to +DNSOverTLS in the Protocols line means the encrypted channel is live.
The Other Managers: nmcli, netplan, systemd-networkd
systemd-resolved isn't the only player. Know which one owns your machine — because they all want to write that symlink. Quick map:
# NetworkManager (desktop default: Ubuntu, Fedora, openSUSE) — per-connection
nmcli con show # find your connection name
sudo nmcli con mod "Wired connection 1" ipv4.dns "1.1.1.1 9.9.9.9"
sudo nmcli con mod "Wired connection 1" ipv4.ignore-auto-dns yes
sudo nmcli con up "Wired connection 1" # reactivate to apply
nmcli dev show wlp2s0 | grep IP4.DNS # confirm what's in effect
# Netplan (Ubuntu 18.04+) — YAML in /etc/netplan/*.yaml
sudo tee /etc/netplan/99-dns.yaml > /dev/null <<'EOF'
network:
version: 2
ethernets:
eth0:
dhcp4: true
nameservers:
addresses: [1.1.1.1, 9.9.9.9]
search: [example.com]
EOF
sudo netplan try # applies with a 120s rollback timer — SSH-safe
sudo netplan apply
# systemd-networkd (Arch, servers) — per-interface .network files
# /etc/systemd/network/10-eth0.network:
# [Network]
# DNS=1.1.1.1
# Domains=example.com
# then: sudo systemctl restart systemd-networkd
Rabbit hole warning: on networkd-rendered netplan, DHCP-provided nameservers can still win over your static nameservers: block unless you add dhcp4-overrides: {use-dns: false}. On desktop Ubuntu the renderer is NetworkManager, so your nmcli settings are the ones that stick — pick the tool for the layer you're actually on.
The Nuclear Option: Static resolv.conf, Pinned with chattr
Sometimes you just want a plain file with two lines, full stop — a lab box, a CTF box, a build VM. Here's the honest way to do it, symlink be damned:
# 1) Remove the managed symlink
sudo rm /etc/resolv.conf
# 2) Write a real static file
sudo tee /etc/resolv.conf > /dev/null <<'EOF'
nameserver 1.1.1.1
nameserver 9.9.9.9
options edns0
EOF
# 3) Pin it so nothing rewrites it
sudo chattr +i /etc/resolv.conf
lsattr /etc/resolv.conf # should show ----i-----------
# Undo later with: sudo chattr -i /etc/resolv.conf
Three traps here, all of which bit me at some point:
- chattr fails on symlinks (
Operation not supported) — you must delete the symlink first. If your distro's manager re-creates it,chattris the wrong tool entirely. - tmpfs ignores chattr (
Inappropriate ioctl for device) — pinning/run/.../resolv.confnever works; /run is RAM. Only pin a real file on ext4/xfs/btrfs. - systemd-resolved detects a static file and switches to "consumer" mode — it stops managing the file. If you later want the daemon back, delete the static file and re-link the stub.
Testing and Troubleshooting Like a Pro
Once configured, prove it — and know which resolver answered, because "it works" and "it works through the server I intended" are different facts:
# Query via the system resolver (note the SERVER line: 127.0.0.53 = stub)
dig example.com
# Bypass everything: ask a specific server directly (CTF/lab favorite)
dig @1.1.1.1 example.com +short
# Walk the full chain root -> TLD -> authoritative
dig example.com +trace
# Reverse lookup
dig -x 1.1.1.1 +short
# What apps ACTUALLY see (uses NSS: hosts file first, then DNS)
getent hosts example.com
# Quick checks with the other tools
host example.com
nslookup example.com
Reading the output: ;; SERVER: 127.0.0.53#53(wlp2s0) means your query hit the systemd-resolved stub — good, the daemon is in the path. If you need the real upstream answer, compare with dig @1.1.1.1. To see which upstream the daemon chose, resolvectl status (Current DNS Server) or cat /run/systemd/resolve/resolv.conf.
When a lookup "should" work but doesn't, run this exact ladder:
# 1. Is it /etc/hosts overriding you?
cat /etc/hosts
# 2. Is the hosts line ordering sane?
grep '^hosts' /etc/nsswitch.conf
# 3. Which nameservers does the system actually have?
resolvectl status | grep -E 'DNS Servers|Current DNS'
# 4. Can the upstream itself answer?
dig @1.1.1.1 example.com +short
# 5. Stale cache? Flush it and check statistics
sudo resolvectl flush-caches
resolvectl statistics # Cache size should drop to 0
# 6. Old habit that no longer works on 22.04+:
# sudo systemd-resolve --flush-caches # -> command not found
That ladder has resolved more "broken internet" tickets in our lab than any single tool. getent hosts is the unsung hero — it answers through NSS, so it reflects exactly what curl and apt will see, including any /etc/hosts entries and search-domain behavior.
Why Your Resolver Choice Is a Security Decision
Here's the part the tutorials skip. By default, your machine resolves through whatever your ISP's or router's DHCP offered — which means every domain you visit is logged and potentially monetized by a party you never agreed with. On untrusted WiFi, a rogue DHCP server can hand you a rogue resolver outright, and unencrypted DNS on the wire lets anyone on the LAN see and tamper with your queries — cache poisoning being the classic payoff.
Fixing that is a two-layer move:
- Pick a resolver you trust to validate: 1.1.1.1 (Cloudflare) and 9.9.9.9 (Quad9) both run DNSSEC validation and keep logs short. Quad9's whole pitch is blocking known-malicious domains.
dig @1.1.1.1 +short txt whoami.cloudflareconfirms you're actually talking to Cloudflare's network. - Encrypt the upstream channel: set
DNSOverTLS=yes(with theip#hostnameSNI form) inresolved.conf, or opportunistic if you switch networks a lot. This stops LAN snooping and injection of your DNS traffic.
One honest caveat before you go full paranoia: encryption moves the trust boundary, it doesn't erase it. Your chosen resolver (Cloudflare, Quad9, whoever) still sees every query — that's inherent to the protocol. The point is you get to choose who that is, instead of inheriting the WiFi's default.
And a trap I want you to avoid: plenty of 2025-2026 blog posts tell you to enable DNSOverHTTPS=yes in resolved.conf. In our testing, that option is not in the mainline systemd man page (man resolved.conf documents DNSOverTLS=, not DoH) — upstream DoH support is still an unmerged draft. If you specifically want DNS-over-HTTPS, verify the option exists on your exact distro build before trusting it, or run a dedicated DoH proxy (stubby, dnscrypt-proxy) on 127.0.0.1 and point resolvectl at it.
Gotchas & Rabbit Holes (The Cheap Lessons)
- Editing resolv.conf directly usually does nothing on systemd-resolved systems — it's a generated symlink. Configure the manager, not the file.
resolvectl dnsis runtime-only. It's perfect for testing, useless for persistence — useresolved.conf.ddrop-ins.- Stub vs. upstream confusion:
127.0.0.53is the local stub. If dig shows it but you expected 1.1.1.1, the daemon is working — checkresolvectl statusfor the real upstream. - DHCP fights back: both netplan (networkd renderer) and NetworkManager need explicit opt-outs (
use-dns: false/ipv4.ignore-auto-dns yes) before your static servers survive a reconnect. systemd-resolveis gone on recent releases — every old tutorial using--set-dnsneeds translating toresolvectlpositional syntax.- chattr +i only works on real files on real filesystems — never on the symlink, never in /run.
Takeaways
- Know your resolution order —
files→ resolver in/etc/nsswitch.confdecides whether/etc/hostsor DNS wins; order is policy, and an attacker who can write/etc/hostsbeats your DNS every time. - Configure the manager, not the file — on systemd-resolved systems,
resolvectl+resolved.conf.ddrop-ins are the supported path; hand-editing the symlink is why your changes "vanish." - Always verify with
dig @<server>— the SERVER line in dig's output tells you who actually answered, which turns "is my DNS broken?" into "is my DNS pointed where I think it is?" - Make upstream encryption deliberate — a trusted, DNSSEC-validating resolver (1.1.1.1, 9.9.9.9) plus
DNSOverTLS=yescloses the LAN-snooping and poisoning window without any new software.
One anchor insight: on modern Linux, nameserver configuration is delegation, not configuration — you set policy in the resolver daemon, and /etc/resolv.conf is just the display.
Related: our SSH hardening for exposed services guide for locking down the rest of your lab box, and the Termux non-root setup guide if your "desktop" is actually a phone. Up next: a NetworkManager vs systemd-networkd comparison and a DNSSEC/DoT deep-dive.
