How DNS Really Resolves a Name: Build Your Own Root, TLD, and Authoritative Servers and Trace Every Step
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.
When you type a hostname, your machine asks exactly one question — and somewhere behind that, a recursive resolver walks an entire tree of servers to find the answer. In this lab you build a tiny DNS hierarchy of your own and watch one name get resolved from the top down:
- a stub resolver (
digon the client) that just asks one question, - a recursive resolver that does the real work,
- a fake root, a TLD (
lab.), and an authoritative server (example.lab.).
The theme is simple: the client asks once, but behind it the recursive resolver walks the delegation chain . -> lab. -> example.lab. until it reaches the server that actually holds the answer.
Reading guide: rfc-notes/dns-recursive-resolution.md
Expected time: 50–65 minutes.
The Goal
By the end, you should be able to fill in this table for www.example.lab:
| Step | Who is asked | What comes back |
|---|---|---|
| 1 | root (a.root.) |
referral: ask ns.lab. for lab. |
| 2 | TLD (ns.lab.) |
referral: ask ns.example.lab. for example.lab. |
| 3 | authoritative (ns.example.lab.) |
answer: www.example.lab. A 203.0.113.10 |
What You Will Learn
- The difference between a stub resolver, a recursive resolver, and an authoritative server.
- What a referral (a delegation with NS records and glue) looks like.
- How a recursive resolver uses root hints to know where to start.
- Why
dig +traceshows one line per level of the hierarchy. - How a cached answer differs from a freshly resolved one (query time and TTL).
This lab does not cover:
- DNSSEC validation (our hierarchy is unsigned on purpose).
- Caching, TTL expiry, and negative answers in depth (that is Lab 06).
- Real root/TLD operations or zone transfers.
- Reverse DNS (PTR) or IPv6 (AAAA) resolution.
Where to Read in the RFCs
These are the essential sections for this lab:
| RFC | Section | What to focus on |
|---|---|---|
| RFC 1034 | 2.3, 3.1 | The domain name space, labels, zones and delegation |
| RFC 1034 | 4.3.1–4.3.2 | Recursive vs. iterative resolution, the name server's resolution algorithm |
| RFC 1034 | 5.3.3 | How a resolver follows referrals |
| RFC 1035 | 3.7, 4.1 | The question / answer / authority / additional sections |
| RFC 8499 | 2, 6 | Terminology: stub resolver, recursive resolver, authoritative server, glue |
| RFC 5737 | 3 | Confirming 203.0.113.0/24 is a documentation prefix |
The Big Picture
We build one client, one recursive resolver, and three authoritative servers: root, TLD, and the zone's authoritative server.
client ---- resolver ----+---- root (serves ".", delegates lab.)
(recursive) +---- tld (serves "lab.", delegates example.lab.)
+---- auth (serves "example.lab.", holds the A record)
The answer:
www.example.lab. A 203.0.113.10 (TTL 60)
The resolver is the hub: it reaches every authoritative server directly and also relays the client's dig +trace packets. The root, TLD, and auth servers never talk to each other — the resolver (or the client during +trace) queries each of them in turn.
203.0.113.0/24 is the RFC 5737 documentation prefix; it stays inside the lab and never leaves.
Note: Everything here uses documentation address space (RFC 5737), so nothing in this lab touches the real internet.
flowchart LR
client["client<br/>stub resolver<br/>10.0.0.2"]
resolver["resolver<br/>recursive<br/>10.0.0.1"]
root["root<br/>serves .<br/>10.0.1.2<br/>delegates lab."]
tld["tld<br/>serves lab.<br/>10.0.2.2<br/>delegates example.lab."]
auth["auth<br/>serves example.lab.<br/>10.0.3.2<br/>www A 203.0.113.10"]
client -- "query www.example.lab" --> resolver
resolver -- "1. . NS?" --> root
resolver -- "2. lab. NS?" --> tld
resolver -- "3. www.example.lab A?" --> auth
sequenceDiagram
participant C as client (stub)
participant R as resolver (recursive)
participant Root as root (.)
participant TLD as tld (lab.)
participant Auth as auth (example.lab.)
C->>R: www.example.lab A? (RD=1)
R->>Root: www.example.lab A?
Root-->>R: referral: lab. NS ns.lab. (glue 10.0.2.2)
R->>TLD: www.example.lab A?
TLD-->>R: referral: example.lab. NS ns.example.lab. (glue 10.0.3.2)
R->>Auth: www.example.lab A?
Auth-->>R: answer: www.example.lab A 203.0.113.10
R-->>C: answer: 203.0.113.10 (cached now)
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 examples/dns-05/Dockerfile. It's a thin wrapper overinternetsystemsconsortium/bind9:9.20that addsiproute2and runsnamedin the foreground.nicolaka/netshoot:latest
The upstream ISC BIND image doesn't ship the ip command, so the ip addr add that containerlab runs via exec fails. That's why we build a local image with iproute2 added first — run.sh builds it automatically before deploying.
Note: On macOS, run this inside a Linux VM, a WSL-equivalent environment, or a Linux VM under OrbStack/Colima.
Running the Lab
All steps run inside the Linux environment where containerlab is installed.
If you have the repo, the quick path deploys, verifies name resolution, collects the dig +trace output, checks cache behavior, and tears everything down for you:
./scripts/labctl.sh run dns-05
Or step through it manually:
1. Move into the working directory
cd protocol-lab/examples/dns-05
2. Read the zones and delegations
Before running anything, read how the three zones connect:
cat root/db.root
cat tld/db.lab
cat auth/db.example.lab
cat resolver/root.hints
How to read them:
root/db.rootserves.and delegateslab.tons.lab.(10.0.2.2).tld/db.labserveslab.and delegatesexample.lab.tons.example.lab.(10.0.3.2).auth/db.example.labservesexample.lab.and answerswww.example.lab. A 203.0.113.10.resolver/root.hintstells the resolver to start ata.root.(10.0.1.2).
Each delegation is written as a pair: an NS record plus a glue A record. Without the glue, the resolver would need a separate resolution just to find ns.lab.'s address.
3. Deploy
Build the BIND image (with iproute2) first, then deploy:
docker build -t protocol-lab/bind9:9.20 .
sudo containerlab deploy -t dns-05.clab.yml
After deploying, confirm the containers are up:
docker ps --format "table {{.Names}}\t{{.Status}}"
You should see all five running:
clab-dns-05-rootclab-dns-05-tldclab-dns-05-authclab-dns-05-resolverclab-dns-05-client
4. Ask once from the client (the stub's view)
docker exec -it clab-dns-05-client dig @10.0.0.1 www.example.lab A
Expected result:
;; ANSWER SECTION:
www.example.lab. 60 IN A 203.0.113.10
;; Query time: 3 msec
;; SERVER: 10.0.0.1#53(10.0.0.1)
The client asks exactly once. The RD (recursion desired) flag is set, so the resolver does the rest of the work.
5. Walk the delegation chain (the iterative view)
dig +trace doesn't hand the job off to the resolver — it follows the referrals itself, one level at a time.
docker exec -it clab-dns-05-client dig +trace @10.0.0.1 www.example.lab A
The key parts of the expected output:
. NS a.root.
a.root. A 10.0.1.2
;; Received ... from 10.0.0.1 ...
lab. NS ns.lab.
ns.lab. A 10.0.2.2
;; Received ... from 10.0.1.2 ... <- root answered
example.lab. NS ns.example.lab.
ns.example.lab. A 10.0.3.2
;; Received ... from 10.0.2.2 ... <- tld answered
www.example.lab. 60 IN A 203.0.113.10
;; Received ... from 10.0.3.2 ... <- auth answered
Watch the ;; Received ... from <IP> lines: the answering server descends from root (10.0.1.2) to tld (10.0.2.2) to auth (10.0.3.2). That is iterative resolution.
6. Observe the cache
Ask the same question again, normally:
docker exec -it clab-dns-05-client dig @10.0.0.1 www.example.lab A
What to look for:
Query timeis smaller than the first run (often 0 msec).- The A record's TTL is now below
60(e.g.54) — it decreases by the number of seconds since the resolver cached it.
You can also prove the answer is cached by turning recursion off:
docker exec -it clab-dns-05-client dig +norecurse @10.0.0.1 www.example.lab A
Even without RD, the resolver can answer 203.0.113.10 from its cache. If the cache were empty, +norecurse would get no answer — the resolver won't go resolve anything new.
7. See the questions the resolver received
The resolver logs incoming queries:
docker logs clab-dns-05-resolver 2>&1 | grep query | tail
Expected result:
client 10.0.0.2#... query: www.example.lab IN A +
You can see the single query from the client. The + means recursion desired. From that one question, the resolver performs the iterative queries to root/tld/auth behind the scenes.
Expected Output
Exact matches matter less than confirming the following.
dig @10.0.0.1 www.example.lab A
ANSWER SECTIONcontains203.0.113.10.flagsshowsqr rd ra(ra= recursion available).
dig +trace @10.0.0.1 www.example.lab A
- Referrals appear in order:
.→lab.→example.lab.. ;; Received ... from 10.0.1.2 / 10.0.2.2 / 10.0.3.2appear step by step.- The final line is
www.example.lab. A 203.0.113.10.
The second dig
Query timedrops.- The TTL counts down from
60.
Why It Works
The DNS namespace is a tree, and each zone carves out child zones via delegation. A delegation is expressed as the child zone's NS records plus glue A records that supply those NS servers' addresses.
The client's stub resolver never walks the tree. It sets RD=1 and hands the whole job to the recursive resolver: "resolve this all the way for me."
The recursive resolver learns its first step (the address of a.root.) from its root hints, and from there:
- Asks root
www.example.lab A?→ root doesn't hold the answer, but returns a referral forlab.. - Asks the
lab.NS (the TLD) the same question → gets a referral forexample.lab.. - Asks the
example.lab.NS (auth) the same question → this time gets an authoritative answer (with theAAflag set).
The resolver returns that answer to the client and caches it for the duration of the TTL. That's why the second query is fast and the TTL appears to count down.
dig +trace re-enacts the resolver's job on the client side, one level at a time — which is why you can read exactly "who answered" at each step.
Common Pitfalls
- Confusing stub and recursive. The client's
digis a stub. It's the resolver that actually walks the tree. - Misreading a referral as an answer. What root and the TLD return isn't the answer — it's "who to ask next." The NS records and glue land in the
AUTHORITY/ADDITIONALsections, notANSWER. - Forgetting glue. If a delegation lists only NS names without A (glue) records, the resolver needs an extra resolution just to find the NS's address — and in this closed lab, that dead-ends.
+traceuses the system resolver by default. We pass@10.0.0.1so the initial. NSquery goes to this lab's resolver. Forget it, anddiggoes hunting for the real root.- DNSSEC. This hierarchy is unsigned, so the resolver runs with
dnssec-validation no. A production resolver validates against the root trust anchor. - Qname minimization. Modern resolvers may not send the full name at every level, asking only the labels each step needs. Your
+traceoutput may look slightly different from the example above, but the skeleton — the delegation chain — is the same.
Cleanup
If you deployed manually, destroy the topology:
sudo containerlab destroy -t dns-05.clab.yml --cleanup
If you used labctl.sh run dns-05, the script runs destroy for you at the end.
Check Your Understanding
- Is the client's
diga stub resolver or a recursive resolver? Which one actually walks the tree? - Does root return the answer for
www.example.lab, or a referral? What does the referral contain? - What is a glue record? Why does a delegation need one?
- What can you read from the
;; Received ... from <IP>lines indig +trace? - Why is the second query faster? Why does the TTL appear smaller than
60? - Under what conditions does a
+norecursequery still get an answer?
References
- RFC 1034: Domain Names - Concepts and Facilities
- RFC 1035: Domain Names - Implementation and Specification
- RFC 8499: DNS Terminology
- RFC 5737: IPv4 Address Blocks Reserved for Documentation
- BIND 9 Administrator Reference Manual
- netshoot: a Docker + Kubernetes network troubleshooting swiss-army container
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 overinternetsystemsconsortium/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-05 performed deploy → verify → destroy, and verification.json returned "status": "verified".
Keeping up with environment drift (fixes this verification required)
The upstream image and containerlab had changed since the lab was authored, so the following fixes were needed:
- The BIND image had switched to an Alpine base.
internetsystemsconsortium/bind9:9.20is nowFROM alpine— noapt-get, andipis the busybox version. It also gainedENTRYPOINT ["/usr/sbin/named","-u","bind"]. The examples/dns-05/Dockerfile was updated toapk add iproute2plusENTRYPOINT [], starting the foregroundnamed -gdirectly via CMD. - The management network grabs the client's default route first. containerlab installs a default route on eth0 (the management network), so
ip route add default via 10.0.0.1failed withFile exists. Sincedig +tracequeries each level's server (10.0.1.2/10.0.2.2/10.0.3.2) directly, explicit routes to each hierarchy subnet were added on the client via the resolver. - A backstop for
dig +trace's NS name resolution. After priming with the root's NS response,+traceresolves the NS names (a.root,ns.lab, …) itself. Without glue it falls back to/etc/resolv.conf(by default Docker's built-in DNS) and fails, so the client'sdns.serverswas pointed at10.0.0.1.
dig +trace: walking the delegation chain from the root
$ docker exec clab-dns-05-client dig +trace @10.0.0.1 www.example.lab A
; <<>> DiG 9.20.23 <<>> +trace @10.0.0.1 www.example.lab A
; (1 server found)
;; global options: +cmd
. 3600 IN NS a.root.
;; Received 75 bytes from 10.0.0.1#53(10.0.0.1) in 0 ms
lab. 3600 IN NS ns.lab.
;; Received 105 bytes from 10.0.1.2#53(a.root) in 1 ms
example.lab. 3600 IN NS ns.example.lab.
;; Received 105 bytes from 10.0.2.2#53(ns.lab) in 0 ms
www.example.lab. 60 IN A 203.0.113.10
example.lab. 3600 IN NS ns.example.lab.
;; Received 121 bytes from 10.0.3.2#53(ns.example.lab) in 1 ms
The ;; Received ... from <IP>(<name>) lines show the client querying each level directly: root (10.0.1.2, a.root) → tld (10.0.2.2, ns.lab) → auth (10.0.3.2, ns.example.lab). Finally, auth returns 203.0.113.10.
The first recursive query (the resolver walks the tree)
$ docker exec clab-dns-05-client dig @10.0.0.1 www.example.lab A
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 63934
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; ANSWER SECTION:
www.example.lab. 60 IN A 203.0.113.10
The client sends rd (recursion desired), and the resolver performs the iterative resolution on its behalf, returning only the answer.
The second query is answered straight from cache
$ docker exec clab-dns-05-client dig @10.0.0.1 www.example.lab A
;; ANSWER SECTION:
www.example.lab. 60 IN A 203.0.113.10
;; Query time: 0 msec
+norecurse: the cached answer comes back even without recursion
$ docker exec clab-dns-05-client dig +norecurse @10.0.0.1 www.example.lab A
;; flags: qr ra; QUERY: 1, ANSWER: 1, AUTHORITY: 1, ADDITIONAL: 2
;; ANSWER SECTION:
www.example.lab. 60 IN A 203.0.113.10
;; AUTHORITY SECTION:
example.lab. 3600 IN NS ns.example.lab.
;; ADDITIONAL SECTION:
ns.example.lab. 3600 IN A 10.0.3.2
An answer comes back even though the rd flag is absent (+norecurse) — because the resolver already cached it during the previous query.
The resolver's query log (iterative resolution as seen from the client)
$ docker logs clab-dns-05-resolver
... (www.example.lab): query: www.example.lab IN A +E(0)K (10.0.0.1)
... (.): query: . IN NS +E(0)DK (10.0.0.1)
... (a.root): query: a.root IN A + (10.0.0.1)
... (ns.lab): query: ns.lab IN A + (10.0.0.1)
... (ns.example.lab): query: ns.example.lab IN A + (10.0.0.1)
... (www.example.lab): query: www.example.lab IN A +E(0)K (10.0.0.1)
During +trace, you can see the client asking the resolver for the addresses of the NS names (a.root / ns.lab / ns.example.lab) — this is the effect of pointing dns.servers at the resolver, as described in the environment-drift section above.
Cleanup
containerlab destroy -t dns-05.clab.yml --cleanup
That's recursive resolution: one question from a stub, a resolver walking referrals from root hints down the delegation chain, and a cache that makes the second answer instant.
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 in Lab 06, we'll dig into what this lab deliberately skipped: caching in depth — TTL expiry, negative answers, and what happens when cached data goes stale.