Module 1 · Lesson 7
Hands-on: Setting Up a Basic DNS Server
⏱ 20 min read
Hands-on: Setting Up a Basic DNS Server
Reading about DNS is one thing. Running a nameserver — watching queries arrive, seeing cache behavior in real time, dealing with zone file syntax errors at the worst possible moment — is what builds actual intuition. Let's do it.
We'll set up two things: BIND9 as an authoritative nameserver for a test zone, and optionally Unbound as a recursive resolver. Both on Ubuntu 22.04. The whole thing takes about 20 minutes.
What You'll Learn That Reading Can't Teach
- What a zone file actually looks like when you write it from scratch
- Why the SOA serial matters (break it, watch what happens)
- How to verify your own nameserver with dig before trusting it
- The difference between "the server is running" and "the server is actually authoritative"
- Why BIND's error messages are... character-building
Setup: BIND9 as Authoritative Nameserver
Install
sudo apt update
sudo apt install -y bind9 bind9utils
Verify it started:
sudo systemctl status bind9
By default, BIND9 starts as a caching resolver. We're going to add an authoritative zone.
Create Your Zone
We'll use test.internal as our zone name. In a real scenario this might be an actual domain you own; for a local experiment, test.internal is fine.
Create the zone file:
sudo nano /etc/bind/db.test.internal
Paste this content:
; Zone file for test.internal
; Semicolons are comments
$ORIGIN test.internal.
$TTL 300
; SOA record — required, must be first
@ IN SOA ns1.test.internal. hostmaster.test.internal. (
2024031501 ; Serial — YYYYMMDDNN format
3600 ; Refresh
900 ; Retry
604800 ; Expire
300 ) ; Negative cache TTL
; Nameserver records
@ IN NS ns1.test.internal.
; Glue record — ns1 lives in this zone, so we need its address here
ns1 IN A 127.0.0.1
; Some test records
@ IN A 192.168.1.10
www IN A 192.168.1.10
api IN A 192.168.1.20
mail IN A 192.168.1.30
; MX record
@ IN MX 10 mail.test.internal.
; TXT record
@ IN TXT "v=spf1 ip4:192.168.1.30 ~all"
; CNAME
blog IN CNAME www.test.internal.
Tell BIND About the Zone
Edit /etc/bind/named.conf.local:
sudo nano /etc/bind/named.conf.local
Add:
zone "test.internal" {
type master;
file "/etc/bind/db.test.internal";
};
Check the Zone File Syntax
Before reloading BIND, check your zone file. BIND's error messages at runtime are not always clear:
sudo named-checkzone test.internal /etc/bind/db.test.internal
Good output:
zone test.internal/IN: loaded serial 2024031501
OK
Any errors will point to the line number. Common mistakes:
- Missing trailing dot on fully-qualified names (
ns1.test.internalinstead ofns1.test.internal.— without the dot, BIND appends the zone name and you getns1.test.internal.test.internal.) - Mismatched parentheses in SOA record
- Wrong number of fields
Check the Named Configuration
sudo named-checkconf
No output means no errors.
Reload BIND
sudo rndc reload
# or
sudo systemctl reload bind9
Check the logs:
sudo journalctl -u bind9 -n 20
You should see something like:
zone test.internal/IN: loaded serial 2024031501
If you see zone test.internal/IN: not loaded due to errors, the zone file has a problem. Run named-checkzone again.
Test Your Zone with dig
Now query your nameserver directly:
# Query your local BIND instance
dig @127.0.0.1 test.internal A
# Expected output:
;; ANSWER SECTION:
test.internal. 300 IN A 192.168.1.10
;; flags: qr aa rd; QUERY: 1, ANSWER: 1
# Note the 'aa' flag — Authoritative Answer. This is correct.
Test a few more records:
# NS record
dig @127.0.0.1 test.internal NS
# MX record
dig @127.0.0.1 test.internal MX
# CNAME resolution
dig @127.0.0.1 blog.test.internal A
# Non-existent record (should return NXDOMAIN)
dig @127.0.0.1 doesnotexist.test.internal A
# SOA record
dig @127.0.0.1 test.internal SOA
For the CNAME query, watch the response:
;; ANSWER SECTION:
blog.test.internal. 300 IN CNAME www.test.internal.
www.test.internal. 300 IN A 192.168.1.10
BIND follows the CNAME chain and includes both records. The client gets the final A record without needing to make a second query.
Make a Change and Watch the Serial
Edit the zone file. Add a new A record:
sudo nano /etc/bind/db.test.internal
Add a record and increment the serial:
; Change serial from 2024031501 to 2024031502
@ IN SOA ns1.test.internal. hostmaster.test.internal. (
2024031502 ; Serial — incremented!
And add:
dev IN A 192.168.1.40
Now reload:
sudo rndc reload
Verify:
dig @127.0.0.1 dev.test.internal A
Now try the same change but without incrementing the serial. Make another change, reload, and query. BIND loaded the zone, but if you had a secondary nameserver, it would ignore the change because the serial didn't increase. This is how zone changes silently fail to propagate.
Set Up Unbound as a Recursive Resolver (Optional but Recommended)
Running your own recursive resolver shows you caching behavior in real time. It's also how you build something you can use daily rather than depending on your ISP.
sudo apt install -y unbound
Unbound's default configuration is functional out of the box. Test it:
dig @127.0.0.1 github.com A
If you got an answer, Unbound is working. Notice the query time in the stats section:
;; Query time: 87 msec ← first query, uncached
Run the same query again immediately:
dig @127.0.0.1 github.com A
;; Query time: 1 msec ← cached!
That's caching in action. The second query returned in 1ms instead of 87ms.
Configure Unbound to Use Your BIND Zone
You can configure Unbound to use your local BIND for test.internal and public DNS for everything else. Create /etc/unbound/unbound.conf.d/local.conf:
server:
# Listen on localhost
interface: 127.0.0.1
port: 5353 # Use non-standard port to avoid conflict with BIND
stub-zone:
name: "test.internal"
stub-addr: 127.0.0.1@53 # Forward test.internal queries to BIND
Test:
dig @127.0.0.1 -p 5353 test.internal A
dig @127.0.0.1 -p 5353 github.com A
BIND Logging for Learning
Turn on query logging to see every query hit your server:
sudo rndc querylog on
sudo tail -f /var/log/syslog | grep named
In another terminal, run some queries:
dig @127.0.0.1 test.internal A
dig @127.0.0.1 www.test.internal A
dig @127.0.0.1 mail.test.internal MX
You'll see each query arrive and be answered. This is the fastest way to understand what your nameserver is actually doing.
Common Pitfalls
Missing trailing dot: ns1.test.internal in a zone file means ns1.test.internal.test.internal. (relative to the zone origin). Always use fully-qualified names (with trailing dot) in zone files, or use $ORIGIN directives carefully.
Forgetting to increment serial: Secondary nameservers won't transfer the zone. Changes silently fail to propagate. Get into the habit of using YYYYMMDDNN format and incrementing NN with each change.
BIND listening on wrong interface: By default on some distributions, BIND listens on all interfaces. If this is a public server, restrict it to specific interfaces in named.conf.options:
options {
listen-on { 127.0.0.1; YOUR_SERVER_IP; };
allow-query { localhost; YOUR_TRUSTED_NETWORKS; };
recursion no; // For an authoritative-only server
};
Running both BIND and Unbound on port 53: They'll conflict. Use different ports, or configure one to handle different roles.
SERVFAIL with no obvious cause: Check /var/log/syslog for BIND errors. DNSSEC validation failures are a common cause on newer distributions where BIND validates by default. You can disable validation for testing with dnssec-validation no; in the options block.
What You Just Learned
Setting up even a basic authoritative server gives you:
- A visceral understanding of zone files — the format you read in debugging tools is the same format you write
- Serial number discipline — it's easy to understand "increment the serial" in theory; having it bite you in practice makes you remember
- The aa flag — you've now seen what an authoritative response looks like vs a cached one
- Query logs — watching queries arrive in real time changes how you think about DNS traffic
Running your own recursive resolver adds:
- Cache behavior you can observe directly
- The difference between "queried a public resolver" and "queried an authoritative nameserver"
- A useful tool for debugging — a resolver you control with logging you can read
Key Takeaways
named-checkzonebefore every reload. BIND's runtime error messages are worse.- Trailing dots in zone files. Always.
hostname.domain.nothostname.domain. - Increment the SOA serial with every change. YYYYMMDDNN is a sane format.
- Use
dig @127.0.0.1to query your server directly; watch for theaaflag in authoritative responses. - Turn on query logging (
rndc querylog on) when learning. See your traffic.
Further Reading
- BIND 9 Administrator Reference Manual
- Unbound Documentation
- ISC BIND GitHub
- DNS Flag Day — What happens when DNS implementations don't follow standards
- RFC 1035, Section 5 — Master file format (zone file syntax)
Module 1 complete. You now have the foundation: what DNS is, how it's structured, what records exist, how resolution works, what runs under the hood, who governs it, and what it feels like to run it yourself. Module 2 goes deeper: DNSSEC, zones in production, and the operational practices that separate DNS you trust from DNS that keeps you up at night.