DNS Caching Explained: Watch TTLs Count Down and NXDOMAIN Get Cached Too
DNS Caching Explained: Watch TTLs Count Down and NXDOMAIN Get Cached Too
Originally published at https://blog.pathvector.dev/protocol-lab-dns-06/ - part of the free Protocol Lab series.
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.A203.0.113.10TTL 60 (short)stable.example.lab.A203.0.113.20TTL 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 theexamples/dns-06/Dockerfile. It's a thin wrapper aroundinternetsystemsconsortium/bind9:9.20that addsiproute2and runsnamedin 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 largerQuery time. - Second query: the TTL has dropped to around
57, andQuery timeis close to0 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: NXDOMAINmeans "this name does not exist."- The
ANSWER SECTIONis empty; instead theAUTHORITY SECTIONcarries 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 smallerQuery time.dig stable.example.lab: TTL 3600 - far longer thanwww.dig missing.example.lab:status: NXDOMAIN.AUTHORITY SECTIONcontains theexample.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
- Who decides the TTL - the authoritative server or the resolver? What does the resolver do with it?
- Why is the second query for the same name faster, and why does it show a smaller TTL?
- Why do
www(TTL 60) andstable(TTL 3600) have different cache lifetimes? - What is the status of the response for
missing.example.lab? What goes into ANSWER and AUTHORITY? - Why does a negative answer carry an SOA? What determines the negative-cache lifetime?
- Explain the difference between NXDOMAIN and NODATA, with an example.
References
- RFC 1035: Domain Names - Implementation and Specification
- RFC 2308: Negative Caching of DNS Queries (DNS NCACHE)
- RFC 8499: DNS Terminology
- RFC 5737: IPv4 Address Blocks Reserved for Documentation
- BIND 9 Administrator Reference Manual
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 aroundinternetsystemsconsortium/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/Dockerfilewas updated the same way as Lab 05's:apk add iproute2,ENTRYPOINT [], and a foregroundnamed -gCMD (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 sameeth1subnet), soip route add default via 10.0.0.1- which collides with the management network's default route and fails withFile 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 DNSS
Comments
No comments yet. Start the discussion.