Building a DNS Server from Scratch in Go: UDP, Binary Protocol Parsing, and RFC 1035
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:
| Bit | Name | Meaning |
|---|---|---|
| 15 | QR | 0 = query, 1 = response |
| 14-11 | Opcode | 0 = standard query |
| 10 | AA | Authoritative Answer |
| 9 | TC | Truncated |
| 8 | RD | Recursion Desired (client sets this) |
| 7 | RA | Recursion Available |
| 6-4 | Z | Reserved |
| 3-0 | RCODE | 0 = 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 datanet— Go standard library for UDP/TCP networking