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 (dig on 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 +trace shows 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 over internetsystemsconsortium/bind9:9.20 that adds iproute2 and runs named in 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.root serves . and delegates lab. to ns.lab. (10.0.2.2).
  • tld/db.lab serves lab. and delegates example.lab. to ns.example.lab. (10.0.3.2).
  • auth/db.example.lab serves example.lab. and answers www.example.lab. A 203.0.113.10.
  • resolver/root.hints tells the resolver to start at a.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-root
  • clab-dns-05-tld
  • clab-dns-05-auth
  • clab-dns-05-resolver
  • clab-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 time is 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 SECTION contains 203.0.113.10.
  • flags shows qr 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.2 appear step by step.
  • The final line is www.example.lab. A 203.0.113.10.

The second dig

  • Query time drops.
  • 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:

  1. Asks root www.example.lab A? → root doesn't hold the answer, but returns a referral for lab..
  2. Asks the lab. NS (the TLD) the same question → gets a referral for example.lab..
  3. Asks the example.lab. NS (auth) the same question → this time gets an authoritative answer (with the AA flag 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 dig is 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 / ADDITIONAL sections, not ANSWER.
  • 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.
  • +trace uses the system resolver by default. We pass @10.0.0.1 so the initial . NS query goes to this lab's resolver. Forget it, and dig goes 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 +trace output 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

  1. Is the client's dig a stub resolver or a recursive resolver? Which one actually walks the tree?
  2. Does root return the answer for www.example.lab, or a referral? What does the referral contain?
  3. What is a glue record? Why does a delegation need one?
  4. What can you read from the ;; Received ... from <IP> lines in dig +trace?
  5. Why is the second query faster? Why does the TTL appear smaller than 60?
  6. Under what conditions does a +norecurse query still get an answer?

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 over 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-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.20 is now FROM alpine — no apt-get, and ip is the busybox version. It also gained ENTRYPOINT ["/usr/sbin/named","-u","bind"]. The examples/dns-05/Dockerfile was updated to apk add iproute2 plus ENTRYPOINT [], starting the foreground named -g directly 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.1 failed with File exists. Since dig +trace queries 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, +trace resolves 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's dns.servers was pointed at 10.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.

Read more