How Linux DNS Resolution Works in 2026
Linux DNS resolution goes through the Name Service Switch (NSS) layer, configured in /etc/nsswitch.conf. The relevant line is almost always:
``` hosts: files dns myhostname ```
This tells glibc to check /etc/hosts first, then DNS, then fall back to the local hostname. The DNS query itself goes to whatever nameserver is listed in /etc/resolv.conf. That file has been the canonical DNS configuration mechanism since 4.3BSD, but in 2026, three separate systems can own or generate it on a running Linux host: the user editing it directly, NetworkManager, or systemd-resolved.
Running `resolvectl status` on a systemd-resolved host gives you the current per-interface DNS servers, the search domains, and whether DNSSEC validation is active. This is the correct diagnostic starting point before touching any config files.
Understanding which system owns resolv.conf on your host is the first step. Run:
``` ls -la /etc/resolv.conf ```
If it is a symlink to /run/systemd/resolve/stub-resolv.conf, systemd-resolved is in stub mode. If it points to /run/systemd/resolve/resolv.conf, it is in passthrough mode. If it is a regular file with no symlink, it is either static or managed by NetworkManager directly.
ls -la /etc/resolv.conf
resolvectl status
The resolv.conf File: Directives and Limits
resolv.conf supports a small set of directives. The ones that matter operationally are nameserver, search, domain, and options.
`nameserver` accepts one IP per line, maximum three entries. The resolver queries them in order, not in parallel (unless you set the `rotate` option). Queries time out after 5 seconds per server by default, so three unresponsive nameservers mean a 15-second wait before failure.
`search` defines a list of domain suffixes appended to short hostnames. If your search line is `search prod.example.com example.com`, resolving `db01` will first try `db01.prod.example.com`, then `db01.example.com`. The kernel limits the search list to 6 domains and 256 characters. Exceeding either silently truncates the list.
`domain` is the legacy single-domain equivalent of search. If both are present, search wins.
Useful options: - `ndots:5` - hostnames with fewer than 5 dots get the search list applied before trying them as absolute names. Kubernetes sets this to 5 by default. - `timeout:2` - reduces per-server timeout to 2 seconds. - `attempts:2` - try each server twice before moving on. - `rotate` - round-robin across nameservers instead of always starting with the first. - `edns0` - enable EDNS0, required for DNSSEC and large UDP responses. - `single-request` - send A and AAAA queries sequentially rather than in parallel. Useful on some broken NAT implementations.
# Example resolv.conf for a production server
nameserver 10.0.0.53
nameserver 10.0.1.53
search prod.example.com example.com
options ndots:2 timeout:2 attempts:2 rotate edns0
Static resolv.conf: When and How
On servers not managed by NetworkManager or cloud-init, a static resolv.conf is the cleanest approach. The problem is that other tools will overwrite it. On Debian and Ubuntu systems, the resolvconf package (not to be confused with the file itself) is a hook-based aggregation system that writes /etc/resolv.conf from multiple input sources. Disable it if you want to manage the file directly:
``` systemctl disable --now resolvconf.service ```
On RHEL 9 and derivatives, NetworkManager manages resolv.conf by default. To make it hands-off, add this to /etc/NetworkManager/NetworkManager.conf:
``` [main] dns=none ```
Then restart NetworkManager and write your static file. NetworkManager will no longer touch it.
To protect a static resolv.conf from accidental overwrites, use the immutable flag:
``` chattr +i /etc/resolv.conf ```
Remove it with `chattr -i` before making changes. This stops any process, including root, from modifying the file without explicitly removing the attribute first. We use this on bare metal database servers where DNS config is stable and we want to catch any automation tool that tries to modify it.
# RHEL/CentOS: stop NetworkManager managing DNS
cat >> /etc/NetworkManager/NetworkManager.conf << 'EOF'
[main]
dns=none
EOF
systemctl restart NetworkManager
# Write your config
cat > /etc/resolv.conf << 'EOF'
nameserver 10.0.0.53
nameserver 10.0.1.53
search prod.example.com
options ndots:2 timeout:2
EOF
# Lock it
chattr +i /etc/resolv.conf
lsattr /etc/resolv.conf
systemd-resolved: Configuration and Modes
systemd-resolved is the resolver daemon in most systemd-based distributions from 2020 onward. It listens on 127.0.0.53:53, provides a D-Bus API, and handles DNSSEC validation and DNS-over-TLS. Understanding its three operational modes is essential before configuring it.
**Stub mode** (default): /etc/resolv.conf symlinks to /run/systemd/resolve/stub-resolv.conf. This file contains only 127.0.0.53 as the nameserver. All queries go to the local stub resolver. Applications that read resolv.conf directly work correctly. This is the mode you want on most servers.
**Passthrough mode**: /etc/resolv.conf symlinks to /run/systemd/resolve/resolv.conf. This file contains the actual upstream DNS servers systemd-resolved has configured. Applications bypass the stub resolver and go directly upstream. Useful for diagnosing issues with the stub listener.
**Foreign mode**: /etc/resolv.conf is a regular file not managed by systemd-resolved. The daemon still runs and provides the D-Bus API and per-interface resolution, but the file is not its symlink.
To switch to stub mode:
``` ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf ```
To configure upstream servers in systemd-resolved, edit /etc/systemd/resolved.conf:
``` [Resolve] DNS=10.0.0.53 10.0.1.53 FallbackDNS=1.1.1.1 8.8.8.8 Domains=prod.example.com example.com DNSSEC=allow-downgrade DNSOverTLS=opportunistic Cache=yes DNSStubListener=yes ```
After editing, restart the service:
``` systemctl restart systemd-resolved resolvectl status ```
`allow-downgrade` for DNSSEC means it validates when the upstream supports it but does not fail for unsigned zones. `opportunistic` for DNS-over-TLS upgrades to TLS when the upstream supports it on port 853 without requiring it. For a high-security environment, use `yes` for both and ensure your internal resolvers support them.
# /etc/systemd/resolved.conf
[Resolve]
DNS=10.0.0.53 10.0.1.53
FallbackDNS=1.1.1.1 8.8.8.8
Domains=prod.example.com example.com
DNSSEC=allow-downgrade
DNSOverTLS=opportunistic
Cache=yes
DNSStubListener=yes
Per-Interface DNS with systemd-resolved and resolvectl
systemd-resolved supports different DNS servers per network interface. This is critical in environments with split-horizon DNS where internal hostnames must resolve through an internal resolver and external names go upstream.
`resolvectl` is the primary management tool. To set DNS servers on a specific interface at runtime:
``` resolvectl dns eth0 10.0.0.53 10.0.1.53 resolvectl domain eth0 prod.example.com ~example.com resolvectl status eth0 ```
The `~` prefix on a domain makes it a routing domain. Queries matching `~example.com` route specifically to the DNS servers assigned to eth0, even if other interfaces have different servers. Without the tilde, it is a search domain.
For persistent per-interface configuration without NetworkManager, use systemd-networkd link files in /etc/systemd/network/:
``` # /etc/systemd/network/10-eth0.network [Match] Name=eth0
[Network] DHCP=no Address=10.0.1.100/24 Gateway=10.0.1.1 DNS=10.0.0.53 10.0.1.53 Domains=prod.example.com ~example.com ```
This survives reboots and does not require NetworkManager. After creating or modifying link files:
``` systemctl restart systemd-networkd resolvectl status ```
We tested this configuration on Debian 12 and Ubuntu 24.04 hosts acting as Kubernetes nodes. The routing domain approach correctly separated in-cluster DNS (handled by CoreDNS at 10.96.0.10) from infrastructure DNS without touching the kubelet or CoreDNS configuration.
# /etc/systemd/network/10-eth0.network
[Match]
Name=eth0
[Network]
DHCP=no
Address=10.0.1.100/24
Gateway=10.0.1.1
DNS=10.0.0.53 10.0.1.53
Domains=prod.example.com ~example.com
NetworkManager and DNS Configuration
NetworkManager has three DNS management modes, set via the `dns=` key in /etc/NetworkManager/NetworkManager.conf under [main]:
- `dns=default` - NM writes /etc/resolv.conf directly. - `dns=systemd-resolved` - NM pushes DNS config to systemd-resolved via D-Bus. The file remains the systemd-resolved symlink. - `dns=none` - NM does not touch /etc/resolv.conf at all.
On a desktop system with multiple network connections, `dns=systemd-resolved` is the correct choice. NM handles connection events and updates the resolver automatically.
To configure DNS for a specific NetworkManager connection:
``` nmcli con mod "Wired connection 1" ipv4.dns "10.0.0.53 10.0.1.53" nmcli con mod "Wired connection 1" ipv4.dns-search "prod.example.com example.com" nmcli con mod "Wired connection 1" ipv4.ignore-auto-dns yes nmcli con up "Wired connection 1" ```
`ipv4.ignore-auto-dns yes` prevents DHCP-assigned DNS from overriding your static configuration for that connection.
To verify what NM has pushed to systemd-resolved:
``` resolvectl status ```
You will see per-interface sections showing the DNS servers and search domains NM has configured.
nmcli con mod "Wired connection 1" ipv4.dns "10.0.0.53 10.0.1.53"
nmcli con mod "Wired connection 1" ipv4.dns-search "prod.example.com example.com"
nmcli con mod "Wired connection 1" ipv4.ignore-auto-dns yes
nmcli con up "Wired connection 1"
resolvectl status
DNS in Containers and Kubernetes
Docker sets up /etc/resolv.conf inside each container based on the host's resolver configuration. If the host is using systemd-resolved in stub mode (nameserver 127.0.0.53), Docker detects this and substitutes the host's upstream DNS servers in the container's resolv.conf instead. This behavior is correct because 127.0.0.53 is not reachable from inside a container's network namespace.
To override DNS for all Docker containers globally, edit /etc/docker/daemon.json:
``` { "dns": ["10.0.0.53", "10.0.1.53"], "dns-search": ["prod.example.com", "example.com"] } ```
Restart the daemon after changes: `systemctl restart docker`.
For per-container override: `docker run --dns 10.0.0.53 --dns-search prod.example.com myimage`.
In Kubernetes, DNS is handled by CoreDNS (the default since 1.11). The kubelet writes /etc/resolv.conf in each pod with: - nameserver pointing to the CoreDNS cluster IP (typically 10.96.0.10) - search list including the pod's namespace, `svc.cluster.local`, `cluster.local`, and whatever search domains the node's resolv.conf has - `ndots:5` in options
The `ndots:5` setting causes every short hostname lookup to first try appending all search domains before attempting the name as-is. This produces extra DNS queries. For high-throughput pods, set ndots to a lower value in the pod spec:
``` dnsConfig: options: - name: ndots value: "2" ```
On our test cluster running Kubernetes 1.31, reducing ndots from 5 to 2 dropped DNS query volume by roughly 40% for pods making frequent external HTTP calls.
# Kubernetes pod DNS config override
apiVersion: v1
kind: Pod
metadata:
name: myapp
spec:
dnsConfig:
options:
- name: ndots
value: "2"
- name: timeout
value: "2"
containers:
- name: myapp
image: myimage:latest
Diagnosing DNS Resolution Problems
The first tool to reach for is `resolvectl query`, not `nslookup` or `dig`. It uses the same resolution path as applications on systemd-resolved hosts:
``` resolvectl query db01.prod.example.com resolvectl query --type=MX example.com ```
If `resolvectl query` works but `dig` gives different results, your application is using the stub resolver correctly and the discrepancy is because `dig` bypasses NSS and reads resolv.conf directly.
To check which DNS server a specific interface is using:
``` resolvectl status eth0 ```
To flush the systemd-resolved cache:
``` resolvectl flush-caches resolvectl statistics ```
`resolvectl statistics` shows cache hits, cache misses, total queries, and DNSSEC validations. On a busy server, a cache hit rate below 50% suggests either a very diverse query pattern or a cache that is too small (configured via `CacheMaxEntries` in resolved.conf, default 4096 on systemd 254+).
For raw packet-level debugging, use `tcpdump` on the loopback to capture queries to the stub resolver:
``` tcpdump -i lo -n port 53 ```
And on the external interface to see what goes upstream:
``` tcpdump -i eth0 -n port 53 ```
If you are troubleshooting split-horizon failures where internal hostnames resolve correctly from the host but not from a container or pod, verify that the container's resolv.conf points to a reachable resolver (not 127.0.0.1 or 127.0.0.53) and that the correct search domains are present inside the container namespace:
``` docker exec mycontainer cat /etc/resolv.conf docker exec mycontainer getent hosts db01.prod.example.com ```
`getent hosts` uses NSS and reflects what applications actually see, making it more reliable than `nslookup` for diagnosing production application failures.
# Diagnostic sequence
resolvectl status
resolvectl query db01.prod.example.com
resolvectl flush-caches
resolvectl statistics
# Container DNS check
docker exec mycontainer cat /etc/resolv.conf
docker exec mycontainer getent hosts db01.prod.example.com
# Capture upstream queries
tcpdump -i eth0 -n port 53 -c 100
Automation and Infrastructure-as-Code Considerations
In Ansible, the correct approach to DNS configuration depends on the host type. For servers using systemd-resolved, use the `ansible.builtin.template` module to manage /etc/systemd/resolved.conf and notify a handler to restart the service. Do not use `lineinfile` on resolv.conf directly.
``` - name: Configure systemd-resolved ansible.builtin.template: src: resolved.conf.j2 dest: /etc/systemd/resolved.conf owner: root group: root mode: '0644' notify: restart systemd-resolved
- name: Ensure stub symlink ansible.builtin.file: src: /run/systemd/resolve/stub-resolv.conf dest: /etc/resolv.conf state: link force: yes ```
For Terraform-provisioned cloud instances, pass DNS configuration through cloud-init user data. On AWS, you can also set DNS options at the VPC DHCP options set level, which propagates to all instances in the VPC automatically. This is preferable to per-instance configuration for large fleets.
If you are running DevOps automation pipelines that configure DNS across environments, tools like taskbotshub.ai can help orchestrate multi-step configuration tasks and validate DNS resolution as part of a post-provisioning workflow, which is useful when you need to confirm resolution consistency across dozens of newly provisioned hosts before releasing them to production.
When naming internal services and domains, the choice of internal domain name matters for long-term operational sanity. Using a real registered domain (like a subdomain of your organization's domain) avoids collisions with public DNS and search domain conflicts. Services like nicename.me are useful when evaluating available domain names for new projects or internal namespaces before you commit them to your DNS infrastructure.
# Ansible task: configure systemd-resolved
- name: Configure systemd-resolved
ansible.builtin.template:
src: resolved.conf.j2
dest: /etc/systemd/resolved.conf
owner: root
group: root
mode: '0644'
notify: restart systemd-resolved
- name: Ensure stub symlink is correct
ansible.builtin.file:
src: /run/systemd/resolve/stub-resolv.conf
dest: /etc/resolv.conf
state: link
force: yes
Security Hardening for DNS Configuration
Unvalidated DNS is an attack surface. Three controls matter in production: DNSSEC validation, DNS-over-TLS, and locking down which processes can modify resolver configuration.
For DNSSEC with systemd-resolved, setting `DNSSEC=yes` in resolved.conf enables strict validation. Queries to zones without valid signatures will fail with SERVFAIL. Test DNSSEC validation with:
``` resolvectl query --type=DNSKEY example.com resolvectl query sigfail.verteiltesysteme.net ```
The second command should return an error if DNSSEC validation is working correctly, because that domain has deliberately broken signatures.
For DNS-over-TLS with known public resolvers, configure the server name for certificate verification:
``` [Resolve] DNS=1.1.1.1#cloudflare-dns.com 8.8.8.8#dns.google DNSOverTLS=yes ```
The `#hostname` suffix tells systemd-resolved to verify the TLS certificate against that hostname. Without it, TLS is used but the certificate is not validated against a hostname.
To prevent non-root processes from modifying DNS configuration, the resolv.conf immutable flag (`chattr +i`) works, but it is a blunt instrument. On systems running systemd-resolved, the correct approach is to leave resolv.conf as the managed symlink and restrict write access to /etc/systemd/resolved.conf to root only (mode 0644 with root ownership is the default). The actual DNS behavior is then only modifiable by root or by the systemd-resolved D-Bus interface, which requires appropriate PolicyKit permissions.
Audit which processes are making DNS queries using systemtap or eBPF. On a kernel 5.15+ host:
``` bpftrace -e 'tracepoint:net:net_dev_xmit { if (args->skbaddr != 0) { printf("%s\n", comm); } }' ```
Or more specifically with a purpose-built tool:
``` dnstop -i eth0 ```
`dnstop` gives per-source-address query counts, making it straightforward to identify which process or container is hammering your resolver.
# Test DNSSEC validation
resolvectl query --type=DNSKEY example.com
resolvectl query sigfail.verteiltesysteme.net
# DNS-over-TLS with certificate verification
# In /etc/systemd/resolved.conf:
# DNS=1.1.1.1#cloudflare-dns.com 8.8.8.8#dns.google
# DNSOverTLS=yes
# Monitor DNS query sources
dnstop -i eth0