Every outbound connection a server makes starts with a DNS lookup, and by default every one of those goes over the network to the resolver in /etc/resolv.conf. For an application that calls a few APIs, a database by hostname and a mail relay, that is hundreds of lookups a minute, each costing 5–50 ms and each failing if the upstream resolver has a bad moment. A local cache answers repeats from memory in under a millisecond and rides out short upstream outages on cached entries.
dnsmasq is the simplest way to get that cache: one small daemon, one config file. This guide sets it up as a caching-only forwarder on CentOS Stream 9, AlmaLinux 8/9 and Rocky Linux 8/9 (the original targeted CentOS 8, which is end-of-life; the steps are identical). It then does the part most tutorials get wrong — making the OS use it without NetworkManager overwriting your changes — and shows how to prove the cache is working.
Before you start: who owns port 53?
RHEL-family systems do not run systemd-resolved by default, so port 53 on localhost is normally free. Check anyway, and check for an existing dnsmasq instance started by libvirt or NetworkManager:
sudo ss -ulnp 'sport = :53'
systemctl is-active systemd-resolved
If systemd-resolved is active (some cloud images enable it), either disable it (sudo systemctl disable --now systemd-resolved) or set DNSStubListener=no in /etc/systemd/resolved.conf before continuing. If libvirt's dnsmasq is listening, it is bound to 192.168.122.1, not 127.0.0.1, and does not conflict as long as you use bind-interfaces below.
Install dnsmasq
sudo dnf install -y dnsmasq bind-utils
bind-utils gives you dig for testing. (On CentOS 7: sudo yum install -y dnsmasq bind-utils.)
Configure a caching-only resolver
Keep the shipped /etc/dnsmasq.conf as documentation and put the active settings in a drop-in:
sudo tee /etc/dnsmasq.d/10-cache.conf >/dev/null <<'CONF'
# Listen only on localhost
listen-address=127.0.0.1,::1
bind-interfaces
# Upstream resolvers come from this file, not /etc/resolv.conf
resolv-file=/etc/resolv.dnsmasq
no-poll
# Cache behaviour
cache-size=10000
neg-ttl=60
min-cache-ttl=120
# Hygiene
domain-needed
bogus-priv
no-hosts
CONF
What each block does:
listen-address+bind-interfaces— serve only the local machine and bind explicitly, so dnsmasq coexists with other DNS daemons on other addresses.resolv-file+no-poll— read upstream servers from a file we control. Without this, dnsmasq reads/etc/resolv.conf, which we are about to point at127.0.0.1— a loop.cache-size=10000— entries, not bytes; the default of 150 is far too small for a server. Memory cost is negligible.neg-ttl=60— cache NXDOMAIN answers for 60 s so a misconfigured app retrying a bad hostname does not hammer upstream.min-cache-ttl=120— treat any record with a TTL under 2 minutes as 2 minutes. This is a deliberate trade: slightly stale answers for CDN-style 30-second TTLs in exchange for far fewer upstream queries. Lower or remove it if you depend on fast DNS failover.domain-needed/bogus-priv— never forward bare hostnames or reverse lookups for private ranges upstream.
Now the upstream servers:
sudo tee /etc/resolv.dnsmasq >/dev/null <<'CONF'
nameserver 1.1.1.1
nameserver 8.8.8.8
nameserver 9.9.9.9
CONF
Use your provider's resolvers here if they are closer. By default dnsmasq sends each query to one server (the one that has been answering fastest) and switches on failure; add all-servers to query them all in parallel and take the first answer, or strict-order to always try them in the listed sequence.
Check the syntax and start the service:
sudo dnsmasq --test
# dnsmasq: syntax check OK.
sudo systemctl enable --now dnsmasq
sudo systemctl status dnsmasq --no-pager
Test the cache before switching the OS to it
Query dnsmasq directly, twice:
dig @127.0.0.1 example.com | grep -E 'Query time|ANSWER SECTION' -A1
# ;; Query time: 23 msec ← first: forwarded upstream
dig @127.0.0.1 example.com | grep 'Query time'
# ;; Query time: 0 msec ← second: from cache
The first query takes the upstream round trip, the second is answered locally. If both are slow, dnsmasq is forwarding every time — see troubleshooting.
Point the system at dnsmasq (the NetworkManager-safe way)
Do not edit /etc/resolv.conf directly. On RHEL-family systems NetworkManager owns that file and will overwrite it on the next lease renewal or reboot. Tell NetworkManager what you want instead:
nmcli -t -f NAME,DEVICE connection show --active
# System eth0:eth0
sudo nmcli connection modify "System eth0" ipv4.dns "127.0.0.1" ipv4.ignore-auto-dns yes
sudo nmcli connection modify "System eth0" ipv6.dns "::1" ipv6.ignore-auto-dns yes
sudo nmcli connection up "System eth0"
ignore-auto-dns yes stops DHCP-supplied resolvers from being added alongside yours. Confirm:
cat /etc/resolv.conf
# Generated by NetworkManager
# nameserver 127.0.0.1
# nameserver ::1
Alternatively, make the change global so every connection uses it: create /etc/NetworkManager/conf.d/dns.conf with
[main]
dns=none
then write /etc/resolv.conf yourself (nameserver 127.0.0.1) and sudo systemctl reload NetworkManager. dns=none means "NetworkManager, don't touch resolv.conf at all".
If you get your upstream resolvers from DHCP and they change, /etc/resolv.dnsmasq is now static. Either maintain it, or point resolv-file at a file NetworkManager writes (/run/NetworkManager/no-stub-resolv.conf on NM ≥ 1.26, which lists the DHCP-provided servers even when resolv.conf points at localhost) and drop no-poll.
Verify end to end
dig example.com | grep -E 'SERVER|Query time'
# ;; Query time: 0 msec
# ;; SERVER: 127.0.0.1#53(127.0.0.1)
getent hosts example.com # what applications actually see
For a live view of what is being cached and forwarded, send dnsmasq SIGUSR1 and read the journal:
sudo kill -USR1 "$(pidof dnsmasq)"
sudo journalctl -u dnsmasq -n 15 --no-pager
# dnsmasq[1842]: cache size 10000, 0/0 cache insertions re-used unexpired cache entries.
# dnsmasq[1842]: queries forwarded 1832, queries answered locally 11920
A healthy ratio after a day is several locally-answered queries for each forwarded one.
Optional: local names and overrides
dnsmasq also reads /etc/hosts (we disabled that with no-hosts to keep it a pure cache; remove the line if you want it) and supports per-domain forwarding and static answers, which is handy on an internal network:
# /etc/dnsmasq.d/20-local.conf
server=/corp.example/10.0.0.53 # send *.corp.example to the internal DNS
address=/dev.local/127.0.0.1 # answer *.dev.local with 127.0.0.1
Reload with sudo systemctl restart dnsmasq (dnsmasq re-reads /etc/hosts on SIGHUP but not its config files).
Troubleshooting
dnsmasq: failed to create listening socket for 127.0.0.1: Address already in use— something else owns port 53.sudo ss -ulnp 'sport = :53'names it; usuallysystemd-resolved(see the first section) or a second dnsmasq.- Every query is slow /
Query timenever drops to 0 — dnsmasq is not caching. Common causes:cache-size=0somewhere in/etc/dnsmasq.confor another drop-in (dnsmasq --testdoes not catch this;grep -r cache-size /etc/dnsmasq.*does), or your client is bypassing it (SERVER:indigoutput is not 127.0.0.1). - Resolution loop / timeouts after the switch — dnsmasq is reading
/etc/resolv.conf(which now says 127.0.0.1) as its upstream. Make sureresolv-file=/etc/resolv.dnsmasqis set and the service was restarted after adding it.sudo journalctl -u dnsmasq | grep 'using nameserver'lists what it actually forwards to. /etc/resolv.confreverts to the DHCP servers — you edited the file by hand. Usenmcli … ipv4.dns+ignore-auto-dns yes, ordns=none.- Works for root, not for a container — containers do not use the host's
127.0.0.1. Give them the host's bridge IP (e.g.172.17.0.1) as DNS, add that address tolisten-address, and open port 53 on the bridge in firewalld (--zone=trusted --add-interface=docker0or a rich rule). - SELinux denials when using a non-standard
resolv-filepath — keep the file in/etc(where it is labellednet_conf_t) orrestoreconit;sudo ausearch -m avc -c dnsmasq -ts recentshows the denial. - Stale answers after a DNS change —
min-cache-ttlis holding them. Flush withsudo systemctl restart dnsmasq, and lower the value if your records change often.
Summary
- Make sure port 53 on localhost is free (
systemd-resolvedoff or stub listener disabled). dnf install dnsmasq, drop-in config withlisten-address=127.0.0.1,resolv-file=/etc/resolv.dnsmasq,no-poll,cache-size=10000.- Upstream servers in
/etc/resolv.dnsmasq;dnsmasq --test; enable the service. - Verify with two
dig @127.0.0.1runs (second one: 0 msec). - Point the OS at it via
nmcli … ipv4.dns 127.0.0.1 ipv4.ignore-auto-dns yes— never by editing resolv.conf directly. kill -USR1+ journal for cache statistics.
Related Resources
- How to set up DNS uptime monitoring — a DNS check against your public records catches the upstream problems a local cache can only paper over for a while
- CoreDNS monitoring — if you outgrow dnsmasq and run a real internal resolver
- DNS lookup tool — compare what the public internet sees with what your cache is answering
- How to use strace to trace Linux system calls — proving that slow application startup is DNS and not the application