Most people reach for BIND out of habit. It’s the default answer, it’s everywhere in Stack Overflow answers from 2009, and it works — in the same way a Swiss Army knife works for surgery. BIND is a resolver, a forwarder, an authoritative server, and a kitchen sink all rolled into one enormous binary with a config language that hasn’t aged well.
If you only need an authoritative nameserver — one that holds zones and answers queries for your domains, nothing else — NSD is the sharper tool. It does one job, it does it fast, and its attack surface is a fraction of BIND’s. NLnet Labs (the same people behind Unbound) built it specifically for this use case, and it’s running some of the internet’s most loaded TLD servers. Official repo: https://github.com/NLnetLabs/nsd
This article covers a full production setup: primary/secondary zone transfers locked down with TSIG, DNSSEC signing with automatic key management, and a day-two operations runbook you can actually use when something breaks at 2 AM.
Why NSD Over BIND for Pure Authoritative Use
The short answer: NSD has no recursive resolver, no forwarder, and no cache. Queries it can’t answer from its zone files get a REFUSED or NXDOMAIN — period. This constraint is a feature. You can’t misconfigure it into accidentally becoming an open resolver. Memory footprint is tiny. Startup is near-instant. The config syntax is clean.
The slightly longer answer: NSD is written to be auditable. The codebase is small enough that security researchers actually read it. When a CVE does appear (rare), it’s patched quickly and the blast radius is small.
If you also need recursive resolution for internal clients, run Unbound alongside it on a different IP or port. Keep concerns separated.
Installation
On Debian/Ubuntu:
apt update && apt install nsd ldnsutils
ldnsutils gives you ldns-keygen, ldns-signzone, and ldns-read-zone — you’ll need all three for DNSSEC. On RHEL/Rocky/Alma, the package is nsd and you may need EPEL. On Alpine it’s in main. Building from source is straightforward but rarely necessary.
After install, NSD runs as the nsd user. Its default config lives at /etc/nsd/nsd.conf and zone files go in /etc/nsd/ (or wherever you point zonesdir).
Base Configuration
NSD’s config format is clean and explicit. Here’s a production-ready starting point for a primary server:
# /etc/nsd/nsd.conf
server:
# Listen on all interfaces; restrict to specific IPs in production
ip-address: 0.0.0.0
ip-address: ::0
# Run as unprivileged user after binding port 53
username: nsd
# Paths
zonesdir: "/etc/nsd/zones"
logfile: "/var/log/nsd.log"
pidfile: "/run/nsd/nsd.pid"
# How many server processes to fork. Match to CPU cores; 2-4 is
# usually plenty for <1M qps.
server-count: 2
# Refuse any query type we don't need to answer
refuse-any: yes
# Hide version string from chaos queries
hide-version: yes
identity: ""
# Raise file descriptor limits if you have many zones
# tcp-count: 100
Gotcha: NSD does not reload config on SIGHUP by default in older versions. Use nsd-control reload instead. Always. Make it muscle memory.
Your First Zone
Put zone files under /etc/nsd/zones/. Standard RFC 1035 syntax:
; /etc/nsd/zones/example.com.zone
$ORIGIN example.com.
$TTL 3600
@ IN SOA ns1.example.com. hostmaster.example.com. (
2026052301 ; Serial — YYYYMMDDNN format, increment on every change
3600 ; Refresh
900 ; Retry
604800 ; Expire
300 ; Negative cache TTL
)
; Nameservers
@ IN NS ns1.example.com.
@ IN NS ns2.example.com.
; Glue records if NS is in-zone
ns1 IN A 203.0.113.10
ns2 IN A 203.0.113.11
; Records
@ IN A 203.0.113.10
www IN A 203.0.113.10
mail IN A 203.0.113.20
@ IN MX 10 mail.example.com.
@ IN TXT "v=spf1 a mx ~all"
Register the zone in nsd.conf:
zone:
name: "example.com"
zonefile: "example.com.zone"
Check syntax before reloading:
nsd-checkconf /etc/nsd/nsd.conf
nsd-checkzone example.com /etc/nsd/zones/example.com.zone
Then reload:
nsd-control reload
Test it:
dig @127.0.0.1 example.com SOA +short
dig @127.0.0.1 www.example.com A +short
Zone Transfers with TSIG
Zone transfers between a primary and secondary are how you keep replicas in sync. Never allow unauthenticated AXFR — anyone can pull your entire zone. Use TSIG (Transaction Signature): a shared HMAC secret that both sides present to authenticate transfers.
Generate a TSIG Key
# Generate a 256-bit HMAC-SHA256 key
tsig-keygen -a hmac-sha256 transfer-key | tee /etc/nsd/transfer.key
If tsig-keygen isn’t available (it’s a BIND tool), use ldns-keygen or just generate the secret manually:
dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64
The output looks like:
key:
name: "transfer-key"
algorithm: hmac-sha256
secret: "cXVpY2tib3dhcmRmb3h0aGVsYXp5ZG9n..."
Primary Configuration
Add to /etc/nsd/nsd.conf:
key:
name: "transfer-key"
algorithm: hmac-sha256
secret: "YOUR_BASE64_SECRET_HERE"
zone:
name: "example.com"
zonefile: "example.com.zone"
# Allow AXFR/IXFR only to ns2 with the TSIG key
provide-xfr: 203.0.113.11 transfer-key
# Notify ns2 when the zone changes
notify: 203.0.113.11 transfer-key
# Notify only the listed addresses (don't notify all NS records)
notify-source: 203.0.113.10
Secondary Configuration
On the secondary server (ns2):
# /etc/nsd/nsd.conf on ns2
key:
name: "transfer-key"
algorithm: hmac-sha256
secret: "YOUR_BASE64_SECRET_HERE" # Same secret as primary
zone:
name: "example.com"
# No zonefile line needed — NSD writes it automatically after transfer
# But you can specify a path for transparency:
zonefile: "example.com.zone"
# Allow inbound transfers from ns1 only
allow-notify: 203.0.113.10 transfer-key
request-xfr: 203.0.113.10 transfer-key
# Where the transferred zone gets stored
# (NSD creates this automatically under zonesdir)
After reloading both servers, force an initial transfer from the secondary:
nsd-control force_transfer example.com
Verify it worked:
nsd-control zonestatus example.com
dig @203.0.113.11 example.com SOA +short
Gotcha: The serial number in the SOA record must increment for NOTIFY/IXFR to trigger a transfer. If you edit a zone file without bumping the serial, the secondary will ignore the notify. Use the YYYYMMDDNN convention and never go backwards.
Gotcha: IXFR (incremental transfers) requires NSD to have the previous zone state in memory. If NSD restarts between changes, it falls back to AXFR. That’s fine — AXFR is a full transfer and it’s not expensive for normal zone sizes.
DNSSEC Zone Signing
DNSSEC lets resolvers verify that your DNS responses haven’t been tampered with. It’s not optional in 2026 — any domain handling email or financial traffic should be signed.
The NSD approach: NSD serves already-signed zone files. You sign offline (or with a script), then reload NSD. This is intentional — NSD doesn’t do inline signing, which keeps signing keys off the live server.
Key Generation
You need two key types:
- KSK (Key Signing Key) — signs the DNSKEY RRset. You upload the DS record for this to your registrar. Rotate annually.
- ZSK (Zone Signing Key) — signs everything else. Rotate more frequently (monthly or quarterly).
cd /etc/nsd/keys
# KSK — algorithm 13 is ECDSA P-256, the modern choice
ldns-keygen -a ECDSAP256SHA256 -k example.com
# ZSK
ldns-keygen -a ECDSAP256SHA256 example.com
This creates .key and .private files. The .private files are your signing keys — protect them. Store them offline or in a secrets manager for KSK at minimum.
Kexample.com.+013+12345.key
Kexample.com.+013+12345.private
Kexample.com.+013+67890.key
Kexample.com.+013+67890.private
Signing the Zone
# Sign the zone. -n: output to stdout. -p: add NSEC3 salt.
# -f: output file. Zone file must have all records including TTLs.
ldns-signzone \
-n \
-o example.com \
-f /etc/nsd/zones/example.com.zone.signed \
/etc/nsd/zones/example.com.zone \
/etc/nsd/keys/Kexample.com.+013+12345 \
/etc/nsd/keys/Kexample.com.+013+67890
The output file contains your original records plus RRSIG, DNSKEY, NSEC3, and NSEC3PARAM records.
Update nsd.conf to serve the signed file:
zone:
name: "example.com"
zonefile: "example.com.zone.signed"
Reload:
nsd-control reload
Verify locally:
dig @127.0.0.1 example.com DNSKEY +short
dig @127.0.0.1 example.com SOA +dnssec +short
Uploading the DS Record to Your Registrar
Extract the DS record:
ldns-key2ds -n -2 /etc/nsd/keys/Kexample.com.+013+12345.key
The -2 flag uses SHA-256. The output is the DS record you paste into your registrar’s control panel. Once propagated, resolvers that support DNSSEC will validate your responses.
Gotcha: After your registrar publishes the DS record, test with dig @8.8.8.8 example.com SOA +dnssec and look for the ad (Authenticated Data) flag in the response flags. If you see SERVFAIL instead, your signature has expired or the DS doesn’t match. DNSSEC validation is unforgiving.
Gotcha: RRSIG records have an expiry. By default ldns-signzone signs for 30 days. Set a cron job to re-sign a week before expiry — not the night before.
Automated Re-signing Script
#!/usr/bin/env bash
# /usr/local/bin/nsd-resign.sh
# Run weekly via cron: 0 3 * * 1 root /usr/local/bin/nsd-resign.sh
set -euo pipefail
ZONE="example.com"
ZONEFILE="/etc/nsd/zones/${ZONE}.zone"
SIGNEDFILE="/etc/nsd/zones/${ZONE}.zone.signed"
KEYDIR="/etc/nsd/keys"
LOGFILE="/var/log/nsd-resign.log"
log() { echo "$(date -Iseconds) $*" | tee -a "$LOGFILE"; }
log "Resigning $ZONE"
# Find keys by glob — handles multiple ZSKs during rollover
KSK=$(ls "${KEYDIR}"/K${ZONE}.+013+*.key | head -1)
ZSK=$(ls "${KEYDIR}"/K${ZONE}.+013+*.key | tail -1)
ldns-signzone \
-n \
-o "$ZONE" \
-f "$SIGNEDFILE" \
-e "$(date -d '+25 days' +%Y%m%d%H%M%S)" \
"$ZONEFILE" \
"${KSK%.key}" \
"${ZSK%.key}"
nsd-checkzone "$ZONE" "$SIGNEDFILE"
nsd-control reload
log "Done. Serial: $(grep 'SOA' "$SIGNEDFILE" | awk '{print $7}' | head -1)"
Make it executable, test it manually, then add to cron.
Ops Playbook
This is the section you’ll actually use when things go sideways.
Check NSD Status
systemctl status nsd
nsd-control status
Reload vs. Restart
# Reload zone files without dropping connections
nsd-control reload
# Reload config AND zones (needed after nsd.conf changes)
nsd-control reconfig
# Full restart — last resort, drops all in-flight TCP connections
systemctl restart nsd
Check Zone Status
# Shows serial, state (primary/secondary), last transfer time
nsd-control zonestatus example.com
# List all zones
nsd-control zonestatus
Force Zone Transfer (on secondary)
nsd-control force_transfer example.com
Add a Zone at Runtime (no restart)
# Add the zone to nsd.conf first, then:
nsd-control addzone example.org example.org.zone
Remove a Zone at Runtime
nsd-control delzone example.org
Test DNSSEC Validation Chain
# Should show 'ad' flag if validation passes
dig @8.8.8.8 example.com SOA +dnssec
# More detailed: check the full chain
delv @8.8.8.8 example.com SOA +vtrace
Dump Zone to Stdout (useful for auditing)
nsd-control write example.com
# or read the zone file directly
ldns-read-zone /etc/nsd/zones/example.com.zone.signed | less
Check Logs
tail -f /var/log/nsd.log
# Or if using journald:
journalctl -u nsd -f
Common Error: "zone example.com: serial unchanged"
You edited the zone file but forgot to bump the SOA serial. Edit the serial, run nsd-checkzone, then nsd-control reload. The secondary won’t pick up changes until the serial increments.
Common Error: Transfer Refused
TSIG error: bad signature
The TSIG secrets don’t match between primary and secondary, or the clocks are too far apart (TSIG has a 5-minute window). Check:
# Both servers must agree on time
timedatectl status
# Look for "System clock synchronized: yes"
Make sure chrony or systemd-timesyncd is running and synced. A clock skew of more than 5 minutes breaks TSIG authentication.
Monitoring the Serial Number
Automate serial checks between primary and secondary:
#!/usr/bin/env bash
# Quick check: primary and secondary serials match
PRIMARY_SERIAL=$(dig @203.0.113.10 example.com SOA +short | awk '{print $3}')
SECONDARY_SERIAL=$(dig @203.0.113.11 example.com SOA +short | awk '{print $3}')
if [[ "$PRIMARY_SERIAL" != "$SECONDARY_SERIAL" ]]; then
echo "WARN: serial mismatch — primary=$PRIMARY_SERIAL secondary=$SECONDARY_SERIAL"
exit 1
fi
echo "OK: serial=$PRIMARY_SERIAL"
Drop this into your monitoring system or run it from cron. It takes half a second and saves you from finding out your secondary is months out of date after a network hiccup.
Production Hardening Checklist
A few things that separate a weekend project from a nameserver you can sleep near:
Firewall rules. Port 53 UDP and TCP should be open to the world on a public authoritative server, but nsd-control (port 8952) must be firewalled to localhost only. Never expose the control socket.
refuse-any: yes — already in the config above. Prevents ANY record DDoS amplification. NSD returns a truncated response instead of the full RRset.
Rate limiting at the network layer. NSD has built-in ratelimit but kernel-level conntrack + iptables/nftables rules are more effective against amplification attacks. Limit UDP response size to 512 bytes if you can tolerate the EDNS fallback.
Separate signing host. The KSK private key should never touch the live nameserver. Sign on an air-gapped machine or at minimum a separate VM, then copy only the signed zone file to the nameserver.
Zone file permissions. chown nsd:nsd /etc/nsd/zones/ && chmod 750 /etc/nsd/zones/. The nsd user needs read access; nothing else should write there except your deployment scripts running as root.
Incremental serials, not timestamps. YYYYMMDDNN format (where NN is a counter from 00-99) is universally understood by monitoring tools, gives you 100 changes per day, and sorts correctly as an integer. Don’t use epoch timestamps — they’re already 10 digits and will overflow the SOA serial field (32-bit unsigned) in 2106. Closer than it sounds for infrastructure you might leave running.
Where NSD Fits in Your Stack
If you’re running the full self-hosted DNS stack: NSD handles authoritative responses for your zones, Unbound handles recursive resolution for your internal clients, and they never need to talk to each other. Run them on separate IPs or ports on the same machine, or on separate VMs if you have the budget.
For high availability: two NSD instances (primary + secondary) with TSIG transfers as shown here is the minimum. If both nameservers are in the same datacenter, you’re not actually redundant — spread them geographically.
For DNSSEC, the biggest operational risk is key management and signature expiry. The re-signing cron job above addresses expiry; for key rollover (ZSK rotation), you need a double-signature period where both old and new ZSK are in the DNSKEY RRset. That’s a separate procedure worth scripting and testing before you need it under pressure.
NSD is boring in the best possible way. It starts fast, uses almost no memory, and the config you write today will still make sense in five years. That’s what authoritative DNS should be.