Networking & Diagnostics7 min read

    Local DNS Caching with dnsmasq on CentOS, AlmaLinux and Rocky Linux

    By Amir— Reliability & Network Engineering
    Share

    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 at 127.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; usually systemd-resolved (see the first section) or a second dnsmasq.
    • Every query is slow / Query time never drops to 0 — dnsmasq is not caching. Common causes: cache-size=0 somewhere in /etc/dnsmasq.conf or another drop-in (dnsmasq --test does not catch this; grep -r cache-size /etc/dnsmasq.* does), or your client is bypassing it (SERVER: in dig output 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 sure resolv-file=/etc/resolv.dnsmasq is set and the service was restarted after adding it. sudo journalctl -u dnsmasq | grep 'using nameserver' lists what it actually forwards to.
    • /etc/resolv.conf reverts to the DHCP servers — you edited the file by hand. Use nmcli … ipv4.dns + ignore-auto-dns yes, or dns=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 to listen-address, and open port 53 on the bridge in firewalld (--zone=trusted --add-interface=docker0 or a rich rule).
    • SELinux denials when using a non-standard resolv-file path — keep the file in /etc (where it is labelled net_conf_t) or restorecon it; sudo ausearch -m avc -c dnsmasq -ts recent shows the denial.
    • Stale answers after a DNS change — min-cache-ttl is holding them. Flush with sudo systemctl restart dnsmasq, and lower the value if your records change often.

    Summary

    1. Make sure port 53 on localhost is free (systemd-resolved off or stub listener disabled).
    2. dnf install dnsmasq, drop-in config with listen-address=127.0.0.1, resolv-file=/etc/resolv.dnsmasq, no-poll, cache-size=10000.
    3. Upstream servers in /etc/resolv.dnsmasq; dnsmasq --test; enable the service.
    4. Verify with two dig @127.0.0.1 runs (second one: 0 msec).
    5. Point the OS at it via nmcli … ipv4.dns 127.0.0.1 ipv4.ignore-auto-dns yes — never by editing resolv.conf directly.
    6. kill -USR1 + journal for cache statistics.