DNS Caching Explained: Watch TTLs Count Down and NXDOMAIN Get Cached Too

This post is part of Protocol Lab, a free, hands-on series for learning networking protocols by building and breaking them in a container lab. All the lab material — topologies, configs, and scripts — lives in the repo: github.com/pathvector-studio/protocol-lab.

Lab 05 walked one name down the DNS tree. This lab reuses the same hierarchy and asks a different question: once the resolver has an answer, how long does it keep it — and what happens when there is no answer at all?

You will observe three things:

  • A positive answer whose TTL counts down while it sits in the cache.
  • A long-TTL name that stays cached far longer than a short-TTL one.
  • A negative answer (NXDOMAIN) that comes back with an SOA record and is itself cached.

Reading guide: rfc-notes/dns-caching-ttl-negative.md

Prerequisite: DNS Lab 05: Recursive Resolution You Can Trace

Expected time: 50–65 minutes.

The Goal

By the end, you should be able to explain this table:

Query Result Cached for
www.example.lab A 203.0.113.10, TTL 60 60 s (short)
stable.example.lab A 203.0.113.20, TTL 3600 3600 s (long)
missing.example.lab A NXDOMAIN + example.lab. SOA 300 s (SOA minimum)

What You Will Learn

  • What a TTL is and who sets it (the authoritative zone, not the resolver).
  • Why a second identical query is faster and shows a smaller TTL.
  • Why records with different TTLs leave the cache at different times.
  • What a negative answer (NXDOMAIN) looks like, and why it carries an SOA.
  • How the SOA minimum controls how long a negative answer is cached (RFC 2308).

This lab does not cover:

  • DNSSEC-authenticated denial of existence (NSEC/NSEC3).
  • Cache poisoning or resolver security.
  • Serve-stale behavior and prefetching.
  • Zone transfers or dynamic updates.

Where to Read in the RFCs

The essential reading for this lab:

RFC Sections What to focus on
RFC 1035 3.2.1, 4.1.3 Resource records and what the TTL field means
RFC 1035 7.4 How a resolver caches answers and discards them by TTL
RFC 2308 1–3 Negative answers, NXDOMAIN vs NODATA, negative caching via SOA
RFC 2308 5 How the SOA minimum determines the negative-cache TTL
RFC 8499 3, 6 Terminology: TTL, authoritative TTL, negative cache
RFC 5737 3 Confirming 203.0.113.0/24 is a documentation prefix

The Big Picture

We use the same five-node topology as Lab 05. This time the focus is not the delegation chain but the resolver's cache.

client ---- resolver ----+---- root  (serves ".")
         (recursive)      +---- tld   (serves "lab.")
                          +---- auth  (serves "example.lab.")

Contents of example.lab.:
  www.example.lab.     A  203.0.113.10   TTL 60     (short)
  stable.example.lab.  A  203.0.113.20   TTL 3600   (long)
  missing.example.lab.  -> no record  => NXDOMAIN + SOA (negative TTL 300)
sequenceDiagram
  participant C as client
  participant R as resolver (cache)
  participant A as auth (example.lab.)

  Note over R: cache is empty
  C->>R: www.example.lab A?
  R->>A: www.example.lab A?
  A-->>R: 203.0.113.10 TTL 60
  R-->>C: 203.0.113.10 TTL 60
  Note over R: cached, TTL counting down

  C->>R: www.example.lab A? (again, 3s later)
  R-->>C: 203.0.113.10 TTL 57
  Note over R: no upstream query; served from cache

  C->>R: missing.example.lab A?
  R->>A: missing.example.lab A?
  A-->>R: NXDOMAIN + example.lab. SOA
  R-->>C: NXDOMAIN (negative-cached for SOA minimum 300)

Note: 203.0.113.0/24 is a documentation prefix (RFC 5737). It is used only inside the lab and never leaks to the real internet.

What You Need

Recommended environment:

  • Linux / WSL2 / a Linux VM
  • Docker
  • containerlab
  • A BIND9 container image
  • A netshoot container image (client tools)

Images used:

  • protocol-lab/bind9:9.20 — built locally from the examples/dns-06/Dockerfile. It's a thin wrapper around internetsystemsconsortium/bind9:9.20 that adds iproute2 and runs named in the foreground.
  • nicolaka/netshoot:latest — the client.

Why the local build? The upstream ISC BIND image doesn't ship the ip command, so the ip addr add calls containerlab issues via exec fail. Building an image with iproute2 added fixes that; run.sh builds it automatically before deploying. If you've already run Lab 05, the same protocol-lab/bind9:9.20 image is reused.

Running the Lab

These steps are run inside the Linux environment where containerlab lives.

If you have the repo, the quick path deploys, verifies the TTL countdown and the NXDOMAIN + SOA behavior, and tears everything down for you:

./scripts/labctl.sh run dns-06

Or step through it manually:

1. Move into the working directory

cd protocol-lab/examples/dns-06

2. Read the zone's TTLs

Before experimenting, read the TTLs in the authoritative zone:

cat auth/db.example.lab

How to read it:

  • www.example.lab. has TTL 60 — short.
  • stable.example.lab. has TTL 3600 — long.
  • There is no record for missing.example.lab. — that's why it will return NXDOMAIN.
  • The last number of the SOA, 300, is the negative-cache TTL (RFC 2308).

3. Deploy

Build the BIND image (with iproute2) first, then deploy:

docker build -t protocol-lab/bind9:9.20 .
sudo containerlab deploy -t dns-06.clab.yml
docker ps --format "table {{.Names}}\t{{.Status}}"

Confirm that clab-dns-06-{root,tld,auth,resolver,client} are all up.

4. Watch the TTL count down

Ask for the same name twice, a few seconds apart:

docker exec -it clab-dns-06-client dig @10.0.0.1 www.example.lab A
sleep 3
docker exec -it clab-dns-06-client dig @10.0.0.1 www.example.lab A

What to look for:

  • First query: www.example.lab. 60 IN A 203.0.113.10, with a noticeably larger Query time.
  • Second query: the TTL has dropped to around 57, and Query time is close to 0 msec.

The TTL is the authoritative server's statement of "you may cache this answer for at most this many seconds." The resolver starts counting down the moment the record enters its cache, and shows clients the remaining seconds.

Wait for the TTL to reach 0 (60 seconds in this lab) and ask again — the TTL jumps back to 60, because the resolver discarded the cached entry and re-queried the authoritative server:

sleep 60
docker exec -it clab-dns-06-client dig @10.0.0.1 www.example.lab A

5. Compare a long TTL with a short one

docker exec -it clab-dns-06-client dig @10.0.0.1 stable.example.lab A

What to look for:

  • stable.example.lab. 3600 IN A 203.0.113.20.
  • Ask again 3 seconds later and stable still shows a large TTL (e.g. 3597) — it stays in the cache far longer than www.

Same resolver, same zone, yet each record has its own cache lifetime. The authoritative TTL is what decides.

6. Ask for a name that doesn't exist (negative answer)

docker exec -it clab-dns-06-client dig @10.0.0.1 missing.example.lab A

What you expect to see:

;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: ...

;; AUTHORITY SECTION:
example.lab.  300  IN  SOA  ns.example.lab. admin.example.lab. 1 3600 900 604800 300

How to read it:

  • status: NXDOMAIN means "this name does not exist."
  • The ANSWER SECTION is empty; instead the AUTHORITY SECTION carries the zone's SOA.
  • The last number of the SOA (300) is how many seconds this negative answer may be cached.

Ask again right away and the resolver won't re-query the authoritative server — it answers NXDOMAIN straight from its negative cache:

docker exec -it clab-dns-06-client dig @10.0.0.1 missing.example.lab A

If Query time has dropped, the negative cache is doing its job.

7. Confirm the cache in the resolver's logs

The resolver logs the queries it receives. On a cache hit, no upstream (auth) query happens.

docker logs clab-dns-06-resolver 2>&1 | grep query | tail

Every query from the client is logged, but while an answer sits in the cache, the iterative queries to root/tld/auth are not repeated.

Expected Output

Exact matches matter less than observing the following:

dig www.example.lab (twice)

  • First query TTL 60; second query TTL has decreased.
  • Second query has a much smaller Query time.

dig stable.example.lab

  • TTL 3600 — far longer than www.

dig missing.example.lab

  • status: NXDOMAIN.
  • AUTHORITY SECTION contains the example.lab. SOA.

Why It Works

The TTL (time to live) is a per-record value the authoritative zone attaches, meaning "cacheable for at most this many seconds." The resolver starts decrementing the moment a record enters its cache and discards it at 0. Hence:

  • The second query is fast because it's answered from the cache.
  • The visible TTL shrinks because the resolver subtracts the time elapsed since caching.
  • stable (TTL 3600) lives longer simply because the authoritative zone said so.

When the answer is "there is no answer," the resolver still doesn't want to re-ask every time. RFC 2308 defines caching for negative answers (NXDOMAIN and NODATA) too. The authoritative server attaches an SOA record to the negative response, and its minimum field (or the SOA's own TTL, whichever is smaller) becomes the negative-cache lifetime. In this lab the SOA minimum is 300, so the NXDOMAIN for missing.example.lab is cached for at most 300 seconds.

In short, the cache works for both positive and negative answers. What differs is where the lifetime comes from: each RR's TTL for ordinary records, the SOA for negative responses.

Common Pitfalls

  • Thinking the resolver sets the TTL. The authoritative zone sets it. The resolver only counts it down and evicts.
  • The TTL doesn't seem to decrease. If both queries land in the same second, there's no visible difference. Leave a few seconds between them.
  • Confusing NXDOMAIN with NODATA. NXDOMAIN means "the name itself doesn't exist." NODATA means "the name exists, but not that record type" (e.g. no A record, but an MX exists). Both come with an SOA and are negatively cached.
  • Why the SOA rides along with negative answers. Without it, the resolver has no way to decide how long it may cache the "doesn't exist" fact.
  • Short TTLs update fast but cost more. TTL is a trade-off between freshness and cache efficiency.
  • This lab's hierarchy is not DNSSEC-signed. In production, negative answers may be cryptographically proven with NSEC/NSEC3 — out of scope here.

Cleanup

sudo containerlab destroy -t dns-06.clab.yml --cleanup

If you used labctl.sh run dns-06, the script runs destroy for you at the end.

Check Your Understanding

  1. Who decides the TTL — the authoritative server or the resolver? What does the resolver do with it?
  2. Why is the second query for the same name faster, and why does it show a smaller TTL?
  3. Why do www (TTL 60) and stable (TTL 3600) have different cache lifetimes?
  4. What is the status of the response for missing.example.lab? What goes into ANSWER and AUTHORITY?
  5. Why does a negative answer carry an SOA? What determines the negative-cache lifetime?
  6. Explain the difference between NXDOMAIN and NODATA, with an example.

References

Verified Run Log (2026-07-05)

This lab has been confirmed reproducible on real hardware.

Environment:

  • Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64)
  • Docker 29.1.3
  • containerlab 0.77.0
  • resolver/root/tld/auth: protocol-lab/bind9:9.20, a thin wrapper around internetsystemsconsortium/bind9:9.20 (BIND 9.20.24)
  • client: nicolaka/netshoot:latest (dig 9.20.23)

Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run dns-06 performed deploy → verify → destroy, and verification.json returned "status": "verified".

Keeping up with environment drift (fixes this verification required)

  • The upstream BIND image had switched to an Alpine base. The examples/dns-06/Dockerfile was updated the same way as Lab 05's: apk add iproute2, ENTRYPOINT [], and a foreground named -g CMD (see Lab 05's verification log for details).
  • The client's default route. The client in this lab only ever talks to the resolver (10.0.0.1, on the same eth1 subnet), so ip route add default via 10.0.0.1 — which collides with the management network's default route and fails with File exists — isn't needed. It was removed to cut the noise.

The TTL countdown (it only shrinks while cached)

$ docker exec clab-dns-06-client dig @10.0.0.1 www.example.lab A
;; ANSWER SECTION:
www.example.lab.	60	IN	A	203.0.113.10

# a few seconds later
$ docker exec clab-dns-06-client dig @10.0.0.1 www.example.lab A
;; ANSWER SECTION:
www.example.lab.	57	IN	A	203.0.113.10
;; Query time: 0 msec

The same name's TTL dropped 60 → 57 — the remaining TTL shrinks by exactly the seconds elapsed since the resolver cached it. Query time: 0 msec signals a cache hit.

Contrast with a long-TTL name

$ docker exec clab-dns-06-client dig @10.0.0.1 stable.example.lab A
;; ANSWER SECTION:
stable.example.lab.	3600	IN	A	203.0.113.20

stable carries TTL 3600 in the authoritative zone, so no re-query happens for a long while. The TTL expresses, in seconds, "how long you may trust this answer."

A nonexistent name → NXDOMAIN + SOA (negative caching)

$ docker exec clab-dns-06-client dig @10.0.0.1 missing.example.lab A

;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 29784
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1

;; AUTHORITY SECTION:
example.lab.		300	IN	SOA	ns.example.lab. admin.example.lab. 1 3600 900 604800 300

status: NXDOMAIN is the negative answer meaning "this name does not exist." The AUTHORITY section carries the example.lab. SOA, and the SOA's last field (300) is the negative TTL — how many seconds the resolver may cache this "doesn't exist" fact. A repeat of the same query is answered instantly from the negative cache (RFC 2308).

Cleanup

containerlab destroy -t dns-06.clab.yml --cleanup

That's DNS caching in a nutshell: the authoritative zone sets the clock, the resolver counts it down, and even "no such name" is an answer worth remembering — for exactly as long as the SOA allows.

Explore the full Protocol Lab series here: github.com/pathvector-studio/protocol-lab. If these labs are useful to you, please ⭐ star the repo on GitHub — it genuinely helps others find the project.

Next up, we'll keep climbing the DNS stack — think DNSSEC and how a resolver can prove an answer (or its absence) is genuine.

Read more