← Posts

Building a DNS Server from Scratch in Go: UDP, Binary Protocol Parsing, and RFC 1035

March 4, 2026 · 12 min read

How I Got Here

In one of my SRE interviews, I was asked to write a basic DNS server in Go. I knew the broad strokes — DNS resolves domain names to IP addresses, it runs on port 53, uses UDP by default, and falls back to TCP for large responses.

But when the interviewer said “write one”, I froze. I’d never looked at what a DNS message actually looks like on the wire. I didn’t know the binary format, how domain names are encoded, or how to construct a valid response. I stumbled through the interview and couldn’t produce working code.

So after the interview, I thought — let’s actually build one. Not to prove anything to anyone, but because I was genuinely curious: what’s inside those bytes?

Turns out, DNS is one of the simplest binary protocols you can implement. The wire format hasn’t fundamentally changed since RFC 1035 in 1987. A basic authoritative server is about 150 lines of Go.

Two Types of DNS Servers

Before writing code, I needed to understand what I was building. There are two fundamentally different kinds of DNS servers:

You ask: "What's the IP for tasnim.dev?"

              ┌─────────────────┐
              │  Your Computer  │
              └───────┬─────────┘
                      │ query

              ┌─────────────────┐
              │   RECURSIVE     │  ← asks others on your behalf
              │   RESOLVER      │    (e.g. 8.8.8.8, 1.1.1.1)
              └───────┬─────────┘
                      │ "I don't know, let me ask..."

              ┌─────────────────┐
              │  ROOT SERVER    │  → "Try .dev servers"
              └───────┬─────────┘

              ┌─────────────────┐
              │  .DEV TLD       │  → "Try ns1.tasnim.dev"
              └───────┬─────────┘

              ┌─────────────────┐
              │  AUTHORITATIVE  │  ← owns the actual records
              │  SERVER         │    "tasnim.dev = 1.2.3.4"
              └───────┘─────────┘

A recursive resolver is the detective — it doesn’t know the answer, but knows how to chase it down through root servers, TLD servers, and authoritative servers. An authoritative server is the source of truth — it owns the zone file and just answers “here’s the record” or “never heard of it.”

I went with authoritative. It’s simpler — essentially a lookup table that speaks the DNS binary protocol. No chasing, no caching, no recursion. Listen on port 53, parse the question, look up the answer, send it back.

The Wire Format

Every DNS message — query and response — has the same structure:

DNS Message:

 ┌──────────────────────────────────┐
 │             HEADER               │  ← always 12 bytes
 ├──────────────────────────────────┤
 │            QUESTION              │  ← what are you asking?
 ├──────────────────────────────────┤
 │             ANSWER               │  ← resource records (response only)
 ├──────────────────────────────────┤
 │           AUTHORITY              │  ← NS records (optional)
 ├──────────────────────────────────┤
 │           ADDITIONAL             │  ← extra info (optional)
 └──────────────────────────────────┘

The Header

The header is always exactly 12 bytes. Six fields, 2 bytes each:

Header (12 bytes):
 ┌──────────────────────────────────┐
 │           ID (16 bits)           │  ← match query to response
 ├──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┤
 │QR│Opcode │AA│TC│RD│RA│Z │RCODE │  ← flags (16 bits)
 ├──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┤
 │         QDCOUNT (16 bits)        │  ← number of questions
 ├──────────────────────────────────┤
 │         ANCOUNT (16 bits)        │  ← number of answers
 ├──────────────────────────────────┤
 │         NSCOUNT (16 bits)        │  ← authority records
 ├──────────────────────────────────┤
 │         ARCOUNT (16 bits)        │  ← additional records
 └──────────────────────────────────┘

The ID is how you match a response to a query — the client picks a random number, the server echoes it back. The flags field packs individual bits together:

BitNameMeaning
15QR0 = query, 1 = response
14-11Opcode0 = standard query
10AAAuthoritative Answer
9TCTruncated
8RDRecursion Desired (client sets this)
7RARecursion Available
6-4ZReserved
3-0RCODE0 = no error, 3 = NXDOMAIN

All fields are big-endian (network byte order) — the most significant byte comes first. This is the standard for virtually every network protocol, and it’s literally called “network byte order.”

Domain Name Encoding

This is the part that surprised me. When a client asks for tasnim.dev, it doesn’t arrive as the string "tasnim.dev". It arrives as length-prefixed labels:

"tasnim.dev" on the wire:

 ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
 │ 6 │ t │ a │ s │ n │ i │ m │ 3 │ d │ e │ v │ 0 │
 └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
   ↑                           ↑               ↑
   length of "tasnim"          length of "dev"  terminator

Each label is preceded by a single byte indicating its length, and the whole thing ends with a 0 byte. No dots — they’re replaced by length prefixes. So mail.tasnim.dev would be: 4 m a i l 6 t a s n i m 3 d e v 0 — 17 bytes total.

The parser reads a length byte, grabs that many characters, and repeats until it hits a zero. It doesn’t matter if a label contains digits — a2z would be encoded as 3 a 2 z, and the parser just reads the 3, then blindly consumes the next 3 bytes. No ambiguity.

Labels are limited to 63 bytes each because the top 2 bits of the length byte are reserved for compression pointers (more on that in the answer section).

Step 1: UDP Listener and Header Parsing

The first thing I wrote was a UDP server that receives raw bytes and parses the 12-byte header:

package main

import (
	"encoding/binary"
	"fmt"
	"log/slog"
	"net"
	"os"
	"strings"
)

type DNSHeader struct {
	ID      uint16
	Flags   uint16
	QDCount uint16
	ANCount uint16
	NSCount uint16
	ARCount uint16
}

func parseHeader(buf []byte) DNSHeader {
	return DNSHeader{
		ID:      binary.BigEndian.Uint16(buf[0:2]),
		Flags:   binary.BigEndian.Uint16(buf[2:4]),
		QDCount: binary.BigEndian.Uint16(buf[4:6]),
		ANCount: binary.BigEndian.Uint16(buf[6:8]),
		NSCount: binary.BigEndian.Uint16(buf[8:10]),
		ARCount: binary.BigEndian.Uint16(buf[10:12]),
	}
}

binary.BigEndian.Uint16() reads 2 bytes from a slice and returns a uint16 in big-endian order. Six calls, six fields, 12 bytes consumed.

I set up the UDP listener on port 2053 (non-privileged, so no sudo needed during development):

func main() {
	addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:2053")
	if err != nil {
		slog.Error("err", err)
		os.Exit(1)
	}

	conn, err := net.ListenUDP("udp", addr)
	if err != nil {
		slog.Error("err", err)
		os.Exit(1)
	}
	defer conn.Close()

	buffer := make([]byte, 512)

	for {
		n, clientAddr, err := conn.ReadFromUDP(buffer)
		if err != nil {
			slog.Error("err", err)
			continue
		}

		if n < 12 {
			continue
		}

		header := parseHeader(buffer)
		fmt.Printf("ID=%d Flags=%d QDCOUNT=%d\n", header.ID, header.Flags, header.QDCount)
	}
}

The 512-byte buffer is the standard maximum for DNS over UDP. Any message shorter than 12 bytes isn’t a valid DNS message, so we skip it.

Testing with dig:

dig @127.0.0.1 -p 2053 tasnim.dev
ID=51757 Flags=288 QDCOUNT=1

The flags value 288 in binary is 0000000100100000 — QR=0 (it’s a query), RD=1 (dig requests recursion by default). QDCOUNT=1 — one question. It worked.

Step 2: Parsing the Question

After the 12-byte header, the question section contains the domain name in length-prefixed encoding, followed by 2 bytes for the query type and 2 bytes for the class:

type DNSQuestion struct {
	Name  string
	Type  uint16
	Class uint16
}

func parseQuestion(buf []byte, offset int) (DNSQuestion, int) {
	var name strings.Builder
	for {
		b := buf[offset]
		offset++
		lengthPrefix := uint16(b)

		if lengthPrefix == 0 {
			break
		}

		for range lengthPrefix {
			bc := buf[offset]
			offset++
			name.WriteString(string(bc))
		}

		name.WriteString(".")
	}

	domainName := strings.TrimSuffix(name.String(), ".")

	t := binary.BigEndian.Uint16(buf[offset : offset+2])
	offset += 2
	c := binary.BigEndian.Uint16(buf[offset : offset+2])
	offset += 2

	return DNSQuestion{
		Name:  domainName,
		Type:  t,
		Class: c,
	}, offset
}

The function returns the parsed question and the offset where it stopped reading — that offset tells us where the answer section should start in the response.

An earlier version of this function used binary.BigEndian.Uint16() to read single bytes — the length prefix and the characters. That’s wrong. Uint16 expects a 2-byte slice and reads a 16-bit value. The length prefix is a single byte, and each character is a single byte. It happened to work by accident because Uint16 read into the next byte and the values landed right. The kind of bug that passes every test and explodes in production.

Testing:

{tasnim.dev 1 1}

Name parsed correctly. Type=1 (A record), Class=1 (IN — internet). Offset=28, which checks out: 12 header + 6 (tasnim) + 1 + 3 (dev) + 1 + 1 (null terminator) + 2 (type) + 2 (class) = 28.

Step 3: Building the Response

A DNS response has three parts: the response header, the echoed question, and the answer record.

Response Header

The header is mostly copied from the query, with a few flags changed:

  • QR = 1 — this is a response
  • AA = 1 — we’re the authoritative server
  • RA = 0 — we don’t support recursion
  • ANCOUNT = 1 — we’re including one answer

Setting individual bits in a 16-bit flags field means bitwise operations:

flags := uint16(0x8400) | (header.Flags & 0x0100)

0x8400 in binary is 1000 0100 0000 0000 — that’s QR=1 (bit 15) and AA=1 (bit 10). The & 0x0100 masks out everything from the query flags except the RD bit (bit 8), which we echo back as the client sent it. The | combines them.

Answer Record

The answer record follows a specific format called a Resource Record:

Answer (Resource Record):
 ┌──────────────────────────────┐
 │  Name (pointer: 0xC00C)     │  ← 2 bytes
 ├──────────────────────────────┤
 │  Type (2 bytes)              │  ← 1 = A record
 ├──────────────────────────────┤
 │  Class (2 bytes)             │  ← 1 = IN
 ├──────────────────────────────┤
 │  TTL (4 bytes)               │  ← time-to-live in seconds
 ├──────────────────────────────┤
 │  RDLength (2 bytes)          │  ← 4 (IPv4 = 4 bytes)
 ├──────────────────────────────┤
 │  RData (4 bytes)             │  ← the IP address
 └──────────────────────────────┘

The 0xC00C name pointer is DNS compression. Instead of repeating the domain name in the answer, you say “go read it at byte offset 12” — which is where the question’s domain name starts. The top 2 bits being 11 signals it’s a pointer (not a length prefix), and the remaining 14 bits are the offset: 0xC000 | 12 = 0xC00C.

The Full Response Builder

func buildResponse(header DNSHeader, question DNSQuestion, buf []byte, questionEnd int) []byte {
	response := make([]byte, questionEnd+16)

	// 1. Header
	binary.BigEndian.PutUint16(response[0:2], header.ID)

	flags := uint16(0x8400) | (header.Flags & 0x0100)
	binary.BigEndian.PutUint16(response[2:4], flags)

	binary.BigEndian.PutUint16(response[4:6], header.QDCount)
	binary.BigEndian.PutUint16(response[6:8], 1)  // ANCOUNT = 1
	binary.BigEndian.PutUint16(response[8:10], 0)  // NSCOUNT = 0
	binary.BigEndian.PutUint16(response[10:12], 0) // ARCOUNT = 0

	// 2. Question (echo back from query)
	copy(response[12:questionEnd], buf[12:questionEnd])

	// 3. Answer
	// 0xC00C = compression pointer to offset 12
	// 0xC0 = 0b1100_0000 → top 2 bits signal "this is a pointer"
	// 0x0C = 12 → the domain name starts at byte 12
	binary.BigEndian.PutUint16(response[questionEnd:questionEnd+2], 0xC00C)

	// Type (A = 1)
	binary.BigEndian.PutUint16(response[questionEnd+2:questionEnd+4], question.Type)

	// Class (IN = 1)
	binary.BigEndian.PutUint16(response[questionEnd+4:questionEnd+6], question.Class)

	// TTL (60 seconds)
	binary.BigEndian.PutUint32(response[questionEnd+6:questionEnd+10], 60)

	// RDLength (4 bytes for IPv4)
	binary.BigEndian.PutUint16(response[questionEnd+10:questionEnd+12], 4)

	// RData (IP address as 4 raw bytes)
	binary.BigEndian.PutUint32(
		response[questionEnd+12:questionEnd+16],
		binary.BigEndian.Uint32(net.ParseIP("1.2.3.4").To4()),
	)

	return response
}

The response buffer is exactly questionEnd + 16 bytes — the header and question section we copied, plus 16 bytes for the answer record (2 pointer + 2 type + 2 class + 4 TTL + 2 rdlength + 4 IP). No trailing zeros.

The main loop ties it together:

header := parseHeader(buffer)
question, offset := parseQuestion(buffer, 12)
response := buildResponse(header, question, buffer, offset)
conn.WriteToUDP(response, clientAddr)

Parse, build, send. That’s the whole server.

Testing It

$ dig @127.0.0.1 -p 2053 tasnim.dev

;; ANSWER SECTION:
tasnim.dev.        60    IN    A    1.2.3.4

;; Query time: 0 msec
;; SERVER: 127.0.0.1#2053(127.0.0.1)

It works. dig parses the response, shows the answer section, correct TTL, correct IP. Query time: 0 msec.

Pointing macOS at It

On macOS, you can route specific domain lookups to a custom DNS server using /etc/resolver/:

sudo mkdir -p /etc/resolver
printf "nameserver 127.0.0.1\nport 2053\n" | sudo tee /etc/resolver/tasnim.dev

macOS checks /etc/resolver/{domain} before the system resolver. Only queries for tasnim.dev hit the custom server — everything else goes through normal DNS. After flushing the DNS cache:

sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder
ping tasnim.dev

The domain resolves through the server I wrote.

What I Learned

The DNS wire format is surprisingly simple — a fixed 12-byte header, length-prefixed domain names, and resource records with a predictable structure. The entire protocol fits in RFC 1035, which is from 1987 and still describes what runs on the internet today.

The hardest part wasn’t the protocol — it was getting the byte-level details right. Using Uint16 on single bytes, forgetting to set the QR flag, sending 512 bytes instead of the exact response size. Each bug was small but would’ve made the response invalid.

What this server doesn’t do: handle multiple record types, support NXDOMAIN for unknown domains, load records from a zone file, or handle TCP fallback. But the core — parsing a binary protocol off UDP and constructing a valid response — is the same mechanism that runs in CoreDNS, BIND, and every other DNS server. The wire format is identical.

If I got this interview question again, I’d know what to write.

References

  • RFC 1035 — Domain Names: Implementation and Specification (the original DNS spec, 1987)
  • RFC 1034 — Domain Names: Concepts and Facilities
  • encoding/binary — Go standard library for reading/writing binary data
  • net — Go standard library for UDP/TCP networking