Module 1 · Lesson 7

Hands-on: Setting Up a Basic DNS Server

20 min read

dnshands-onBIND9unboundzone-fileSOAdig

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.internal instead of ns1.test.internal. — without the dot, BIND appends the zone name and you get ns1.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-checkzone before every reload. BIND's runtime error messages are worse.
  • Trailing dots in zone files. Always. hostname.domain. not hostname.domain.
  • Increment the SOA serial with every change. YYYYMMDDNN is a sane format.
  • Use dig @127.0.0.1 to query your server directly; watch for the aa flag in authoritative responses.
  • Turn on query logging (rndc querylog on) when learning. See your traffic.

Further Reading


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.