>zone//live
online

overview

--:--:-- · uptime · v1
total queries
0
udp0
tcp0
doh0
dot0
cache hit rate
0%
hits0
miss0
blocked
0
failed up0
authoritative0
uptime
started
p50
p95
p99
avg
latency histogram · dns_query_duration_seconds 0 samples
query log 0
top domains

zones

authoritative records root
zones 0
name type ttl created

records

per-zone resource records
name type rdata ttl

nameservers

authoritative delegation — NS & SOA records
add NS record
authority map 0
zone type hostname ttl

blocklist

NXDOMAIN / sinkhole targets
add / import
blocked 0
domain response added

upstream

recursive resolver fallback chain
add resolver
resolvers 0
# address port priority status

cache

in-memory LRU · TTL-bound
entries
0
capacity
hit rate
0%
misses
0
actions
invalidates every entry, forces fresh upstream lookups

query log

ring buffer · newest first
entries 0

config

server key-value store
master token unset
god-mode bearer for REST & dashboard
current
This token bypasses scope checks (admin everywhere). It’s shown only immediately after rotation; the server stores it in the config.api_token row. Prefer scoped API keys (admin/write/read) for day-to-day use.
set / update
entries 0

docs

every feature, every flag

quick start

Get a running DNS server with one authoritative zone in under a minute.

# 1. install the single runtime dependency
pip install aiohttp

# 2. initialise the database (creates dns.db in cwd)
python dns.py init-db

# 3. create your first zone with a primary nameserver + admin
python dns.py add-zone example.com \
  --ns ns1.example.com --admin admin.example.com

# 4. add a few records
python dns.py add-record example.com www   A    93.184.216.34
python dns.py add-record example.com '@'   MX   mail.example.com --priority 10

# 5. start the server (use high port if you aren't root)
python dns.py start --port 15353 --api-port 8088 --no-doh --no-dot

Watch the startup log for a line like API token: AfNzTY8C... — that token is required for every REST or dashboard API call.

tipPaste the token into the dashboard via the token button in the top-right, or reach the authentication modal by trying any tab — a 401 response prompts you automatically.

dns protocols

The server speaks four transports concurrently. Each runs in the same asyncio event loop, all answered by the same query router.

protocoltransportdefault portnotes
UDPdatagram53Primary. Responses >512 bytes set TC flag; client retries over TCP.
TCPstream53Length-prefixed (2-byte) per RFC 1035.
DoTTLS over TCP853Self-signed cert auto-generated; override via tls_cert_path / tls_key_path.
DoHHTTPS POST443POST /dns-query with application/dns-message, RFC 8484.

Override any port at start time with --port, --api-port, --doh-port, --dot-port. Disable encrypted transports with --no-doh and --no-dot. All four listeners bind both 0.0.0.0 and ::, so IPv6 clients are reachable without extra configuration.

# test UDP + TCP
dig @127.0.0.1 -p 15353 www.example.com A
dig @127.0.0.1 -p 15353 +tcp www.example.com A

zones

A zone is the server’s authoritative delegation for a domain. It is the root at which records hang and carries an SOA (Start of Authority) that other nameservers use to track freshness.

create

python dns.py add-zone example.com \
  --ns ns1.example.com --admin admin.example.com

When you pass --ns and --admin, an SOA record is automatically inserted with that mname/rname plus sensible defaults (serial 1, refresh 3600, retry 900, expire 604800, minimum 86400).

list & delete

python dns.py list-zones
python dns.py delete-zone example.com   # cascades to records

serial auto-increment

Every non-SOA record insert increments the zone’s SOA serial automatically so secondary nameservers pick up the change on their next poll.

records

Twelve record types are supported. rdata is stored as human-readable text and encoded to binary wire format only at query time.

typerdata formatexample
Adotted IPv493.184.216.34
AAAAIPv62606:2800:220:1:248:1893:25c8:1946
CNAMEtarget domainwww.example.com
MXpriority host10 mail.example.com
NSnameserver hostns1.example.com
TXTfree-form stringv=spf1 mx -all
SOAmname rname serial refresh retry expire minimumns1.example.com admin.example.com 1 3600 900 604800 86400
SRVpriority weight port target10 60 5060 sip.example.com
CAAflags tag value0 issue letsencrypt.org
PTRtarget domainhost.example.com
SPFsame as TXTv=spf1 ip4:1.2.3.0/24 -all
NAPTRorder preference flags service regexp replacement100 10 "u" "E2U+sip" "!^.*$!sip:info@example.com!" .

convenience flags

The CLI understands --priority, --weight, --port for ergonomic MX/SRV entry — they are prepended to rdata automatically:

python dns.py add-record example.com @    MX  mail.example.com --priority 10
python dns.py add-record example.com _sip._tcp  SRV  sip.example.com \
  --priority 10 --weight 60 --port 5060

routing columns

Every record also carries three optional fields used by the routing engine: weight (weighted selection), geo (2-letter ISO country or default), and enabled (soft failover). See weighted / geo routing for details.

wildcards and CNAME chasing

  • Name * (or *.foo) matches any label under that point in the zone.
  • CNAME records are chased automatically: the server returns the CNAME plus the terminal A/AAAA answer in one response.

nameservers

The nameservers tab is a filtered view of NS and SOA records across every zone on the server, with a compose bar to add an NS entry in one shot.

  • NS records declare which hosts are authoritative for a zone. You typically need at least two.
  • SOA carries zone metadata. serial auto-bumps on every non-SOA record change.
  • The SOA column renders as mname rname · serial N for legibility.
# add NS records from the CLI
python dns.py add-record example.com example.com NS ns1.example.com
python dns.py add-record example.com example.com NS ns2.example.com

blocklist

Two matching strategies and two response behaviours.

match types

  • Exactads.example.com matches that single FQDN.
  • Wildcard*.tracker.example.com matches any subdomain of tracker.example.com (but not tracker.example.com itself — add that separately if needed).

response types

  • A / AAAA queries for a blocked name return a synthetic 0.0.0.0 / :: answer (sinkhole) so clients fail fast without retrying.
  • All other record types return NXDOMAIN.

bulk import

Paste one domain per line into the bulk import textarea in the blocklist tab, or via CLI:

python dns.py import-blocklist /path/to/hosts.txt

upstream forwarding

When a query is not authoritative and not in cache, it is forwarded upstream. Resolvers are tried in ascending priority order; the first to return a non-SERVFAIL response wins.

  • Default resolvers on fresh init-db: Google 8.8.8.8, Cloudflare 1.1.1.1, Quad9 9.9.9.9.
  • UDP first; if the upstream returns a truncated (TC=1) response, the same query is retried over TCP.
  • Per-resolver timeout defaults to 2 seconds — configurable at construction time.
  • All upstream failures increment upstream_failures in /api/stats.
python dns.py add-upstream  1.0.0.1  --priority 5
python dns.py list-upstream

cache

LRU cache with per-entry TTL, keyed by (domain, record_type).

  • Default capacity: 10,000 entries — oldest entry evicted when full.
  • TTL is honoured from the minimum TTL across the upstream response’s answer section.
  • Lazy expiry: an entry is only evicted when it’s looked up after its TTL.
  • Dashboard cache tab shows size, capacity, hit rate, miss count, and a flush action.
python dns.py flush-cache   # or click Flush Cache in the dashboard

cli reference

All commands accept --db /path/to/dns.db (default: dns.db in cwd).

server

commanddescription
init-dbcreate schema, seed default upstream resolvers
startrun all listeners; flags: --port, --api-port, --doh-port, --dot-port, --no-doh, --no-dot, --log-level

zones

commanddescription
add-zone NAMEflags: --ns, --admin (creates SOA record when both provided)
list-zoneslist every zone
delete-zone NAMEdelete zone; cascades to records

records

commanddescription
add-record ZONE NAME TYPE RDATAflags: --ttl, --priority, --weight, --port
list-records ZONElist records in zone
delete-record IDdelete record by primary key

proxy

commanddescription
add-upstream ADDRflags: --port (53), --priority (0)
list-upstreamlist resolvers in priority order
block DOMAINflag: --response nxdomain|zero
unblock DOMAINremove from blocklist
import-blocklist FILEbulk import, one domain per line
list-blockedlist blocked domains
flush-cacheinvalidate every cache entry

config

commanddescription
set-config KEY VALUEupsert a key/value in the config table
get-config KEYread a config value

zones in bulk

commanddescription
import-zone ZONE FILE [--replace]ingest a BIND zone file; - reads stdin
export-zone ZONEwrite BIND zone file text to stdout

dnssec

commanddescription
dnssec-enable ZONEgenerate ECDSA P-256 key, publish DNSKEY, print DS
dnssec-disable ZONErevoke signing
dnssec-ds ZONEemit DS record for publication at the parent
dnssec-status ZONEshow signing state + key tag

rest api

Every /api/* call requires Authorization: Bearer <token>. The token is printed in the server log on startup, or set a stable one via set-config api_token <value> before starting the server.

methodpathdescription
GET/api/statslive server counters + uptime + top domains
GET/api/zoneslist zones
POST/api/zonescreate zone {name, ns?, admin?}
DELETE/api/zones/:namedelete zone and all its records
GET/api/zones/:name/recordslist records for a zone
POST/api/zones/:name/recordsadd record {name, type, rdata, ttl?}
PUT/api/records/:idupdate record fields
DELETE/api/records/:iddelete record by id
GET/api/blocklistlist blocked
POST/api/blocklistblock one {domain}
POST/api/blocklist/importbulk {domains: [...]}
DELETE/api/blocklist/:domainunblock
GET/api/upstreamlist resolvers
POST/api/upstreamadd resolver {address, port?, priority?}
DELETE/api/upstream/:idremove resolver
GET/api/cache/statscache metrics
DELETE/api/cacheflush entire cache
GET/api/logs?limit=Ntail of recent queries (default 100)
GET/api/configall config keys
PUT/api/config/:keyupdate value {value}
POST/api/zones/:name/importingest BIND zone file (text/plain body); ?replace=1 wipes first
GET/api/zones/:name/exportserialise as BIND zone file
GET/api/zones/:name/dnssecDNSSEC status + DS
POST/api/zones/:name/dnssecgenerate zone key, return DS
DELETE/api/zones/:name/dnssecrevoke signing
GET/api/domains/statusNamecheap auth state + sandbox/production mode
GET/api/domains/search?q=Xcheck TLD availability + pricing
POST/api/domains/registerregister a domain (requires contact block)
GET/metricsPrometheus text-format exposition (unauthenticated)
# example: create a zone from curl
curl -X POST http://127.0.0.1:8088/api/zones \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"demo.local","ns":"ns1.demo.local","admin":"admin.demo.local"}'

dashboard views

tabwhat it shows
overviewlive stats (total, hit rate, blocked, uptime), sparklines, streaming query log, top domains
zoneszone CRUD with created date, type, TTL
recordsper-zone record table with color-coded type chips and an add-record modal
nameserversNS & SOA records across every zone in one authority map
blocklistadd single / bulk import / unblock
upstreamresolver chain with priority, add/remove
cachesize, capacity, hit rate, misses, flush action
query logfull ring-buffer tail (newest first), pause/refresh controls
configread/write the server config key-value table
domainsNamecheap search & register (set namecheap_* config first)
deploystep-by-step DigitalOcean + Cloudflare production playbook
docsthis page
hotkeysIn any modal, Enter submits and Esc cancels. Click the backdrop to dismiss.

config keys

Stored in the config SQLite table; read by the server at startup and by the CLI on demand.

keydefaulteffect
api_tokenrandom on startuppersistent bearer token for REST / dashboard
tls_cert_pathauto-generated self-signedPEM cert used for DoT & DoH
tls_key_pathauto-generated self-signedPEM private key for DoT & DoH
rate_limit_per_sec30per-IP token refill rate; 0 disables
rate_limit_burst60per-IP bucket capacity
query_log_size10000max rows kept in query_log table
axfr_allow(empty)comma-separated CIDRs allowed to AXFR
geoip_db_path(empty)path to GeoLite2 mmdb (enables geo routing)
namecheap_api_user(empty)Namecheap API username
namecheap_api_key(empty)Namecheap API key
namecheap_client_ip(empty)whitelisted caller IP (sandbox or production)
namecheap_modesandboxsandbox or production
python dns.py set-config api_token   my-stable-bearer-token
python dns.py set-config tls_cert_path /etc/letsencrypt/live/dns.me/fullchain.pem
python dns.py set-config tls_key_path  /etc/letsencrypt/live/dns.me/privkey.pem

ipv6 & rate limiting

Every listener now binds both 0.0.0.0 and :: — resolvers on IPv6-only networks reach the server without manual configuration. TCP/DoT bind a single dual-stack socket; UDP needs two separate sockets per OS portability.

rate limiting

Per-source-IP token bucket applied before any other routing work. Defaults: 30 q/s sustained with a 60-query burst. Tuneable via config keys (set and restart):

python dns.py set-config rate_limit_per_sec 10
python dns.py set-config rate_limit_burst   30
# disable entirely
python dns.py set-config rate_limit_per_sec 0

Denied queries return RCODE=REFUSED and increment the rate_limited stats counter exposed on /metrics and the overview tab.

edns0

The OPT pseudo-RR (RFC 6891) is parsed from every query’s additional section. We honour the client’s advertised UDP buffer size for truncation decisions (instead of the RFC 1035 512-byte limit) and echo an OPT record back in responses advertising our own 4096-byte buffer. Clients using DNSSEC (dig +dnssec, stub resolvers, browsers) that set the DO flag trigger the RRSIG signing path documented below.

axfr / secondary

AXFR (RFC 5936) zone transfer over TCP, gated by a per-zone allowlist.

# let cloudflare secondary pull zones (their v4 & v6 ranges)
python dns.py set-config axfr_allow \
  "173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,\
141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,\
197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,\
104.24.0.0/14,172.64.0.0/13,131.0.72.0/22"
  • Empty or unset axfr_allow → every AXFR returns REFUSED.
  • UDP AXFR is always rejected (RFC deprecated).
  • Response format: SOA + all records + trailing SOA in one DNS message.
  • Stats counters: axfr_requests, axfr_denied.

zone files

Round-trip BIND master-file format via CLI or REST.

# CLI export & import
python dns.py export-zone example.com > /tmp/example.zone
python dns.py import-zone example.com /tmp/example.zone --replace

# REST (text/plain body)
curl -H "Authorization: Bearer $TOKEN" \
     http://127.0.0.1:8088/api/zones/example.com/export
curl -X POST -H "Authorization: Bearer $TOKEN" \
     --data-binary @/tmp/example.zone \
     "http://127.0.0.1:8088/api/zones/example.com/import?replace=1"

Supported: $ORIGIN, $TTL, line comments, multi-line parenthesised RRs, relative names resolved against $ORIGIN. Not supported: $INCLUDE, $GENERATE, hex/base64 binary literals.

dnssec signing

ECDSA P-256 (algorithm 13, RFC 6605). Single combined KSK+ZSK per zone — MVP trade-off; no rollover automation and no NSEC3. Requires the cryptography package (in requirements.txt).

enable on a zone

python dns.py dnssec-enable example.com
signed example.com with key tag 12345 (alg 13)
publish the DS record below at the parent zone:
  example.com. IN DS 12345 13 2 ABCD...EF0123

python dns.py dnssec-ds      example.com   # reprint later
python dns.py dnssec-status  example.com
python dns.py dnssec-disable example.com

what happens at query time

  1. Client sends query with EDNS OPT record and the DO flag set.
  2. Authoritative response is built normally (A/AAAA/MX/NS answers).
  3. For each RRset in the answer and authority sections, an RRSIG is appended — signed with the zone key, SHA-256, raw r||s (64 bytes).
  4. For NXDOMAIN, an NSEC proof of denial is added to the authority section and signed.
  5. The DNSKEY record lives at the zone apex — queryable with dig DNSKEY example.com.

REST

GET/api/zones/:name/dnssecstatus + DS
POST/api/zones/:name/dnssecgenerate key, return DS
DELETE/api/zones/:name/dnssecrevoke signing

weighted / geo routing

Three optional columns on every record: weight (default 0), geo (2-letter ISO country code or "default"), and enabled (default 1). POST them on /api/zones/:name/records or via the REST update endpoint.

selection algorithm

  1. Drop records where enabled = 0 (manual failover toggle).
  2. If any record carries a geo tag, prefer those matching the client’s country (from GeoIP lookup); fall back to records with geo unset or default.
  3. If any record has weight > 0, return a single record via weighted-random selection. Otherwise return the whole filtered RRset (classic "all A records" behaviour).

geo lookup

Optional maxminddb dependency + a GeoLite2-Country mmdb file. If either is missing, geo filtering is skipped and selection falls back to weighted-only. Configure:

pip install maxminddb
python dns.py set-config geoip_db_path /var/lib/GeoIP/GeoLite2-Country.mmdb

examples

# A/B test: 90% traffic to new infra, 10% to old
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"www","type":"A","rdata":"1.1.1.1","weight":90}' \
  http://127.0.0.1:8088/api/zones/example.com/records
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"www","type":"A","rdata":"2.2.2.2","weight":10}' \
  http://127.0.0.1:8088/api/zones/example.com/records

# geo: serve eu/us from separate POPs, everyone else from the default
curl ... -d '{"name":"www","type":"A","rdata":"34.1.1.1","geo":"US"}'  ...
curl ... -d '{"name":"www","type":"A","rdata":"35.2.2.2","geo":"DE"}'  ...
curl ... -d '{"name":"www","type":"A","rdata":"10.0.0.1","geo":"default"}' ...

metrics & logging

prometheus /metrics

Text-format exposition at /metrics — outside /api/*, so no bearer token required (standard Prometheus UX).

curl http://127.0.0.1:8088/metrics

Exposed series:

  • dns_uptime_seconds (gauge)
  • dns_queries_total{protocol=udp|tcp|doh|dot} + dns_queries_grand_total
  • dns_authoritative_responses_total
  • dns_cache_hits_total, dns_cache_misses_total, dns_cache_entries, dns_cache_hit_rate
  • dns_blocked_queries_total
  • dns_upstream_failures_total
  • dns_rate_limited_total

Scrape with the standard Prometheus job config (15-second interval works fine).

persistent query log

The QueryLog previously lived in an in-memory deque; it now writes to a dedicated query_log SQLite table with a configurable size cap (default 10,000):

python dns.py set-config query_log_size 50000

Periodic trim (every 200 inserts) deletes rows beyond the cap. The dashboard query log tab and /api/logs both read from the table — you now get history across restarts.

troubleshooting

port 53 says “permission denied”

Binding to ports below 1024 requires elevated privileges on most systems. Either run as root, grant CAP_NET_BIND_SERVICE to Python (Linux), or pick a high port: --port 15353.

port 5353 is busy

macOS mDNSResponder owns 5353. Use 15353 or another high port for local testing.

dashboard stats stay at zero

The dashboard polls /api/stats every 2.5 seconds while the tab is visible. If it stays blank, open the browser console — the network panel will show 401 (missing or wrong token) or ECONNREFUSED (server not listening on that port).

MX / SRV rdata looks wrong in query answers

The rdata text is parsed at wire-encoding time; numeric fields must be separated by spaces. MX: 10 mail.example.com. SRV: 10 60 5060 sip.example.com. If you used the --priority/--weight/--port flags, the CLI wrote the rdata in this form automatically.

blocked A query returns 0.0.0.0, not NXDOMAIN

This is intentional (sinkhole behaviour). The client resolves instantly to a bogus address and stops retrying. Use a non-A type (e.g. dig +short TXT foo) if you want to see NXDOMAIN for blocked names.

deploy

digitalocean · cloudflare · production checklist

overview

End-to-end playbook for running this as a real authoritative nameserver on a $6/month DigitalOcean droplet, with Cloudflare handling the front edge and a Let’s Encrypt certificate for the dashboard and encrypted DNS.

The topology you’re building:

# Resolution path
browser / resolver                                      dns.yourdomain.com
     │                                                          │
     │   1  dig www.yourdomain.com                              │  (droplet, 192.0.2.10)
     ├──────────────────────────────────────────────────────►   │
     │   2  UDP:53 / TCP:53 / 853 / 443 (dns listeners)         │
     │                                                          │
     │                ┌─────────────────────────┐               │
     │                │ systemd · dns.service   │               |
     │                │  └ /opt/dns/.venv/...   │               │
     │                └─────────────────────────┘               │
     │                                                          │
# Management path
your laptop                                             https://dns.yourdomain.com
     │                                                          │
     │   https:443 ──► Caddy ──► localhost:8088 (api)           │
     └─────────────────────────────────────────────────────►    │
costDroplet: $6/mo (1GB shared). Reserved IP: free while attached. Cloudflare free plan: free. Registrar: ~$10/year. Total: ~$82/year.

1 · digitalocean droplet

create

  1. DigitalOcean → CreateDroplets.
  2. Image: Ubuntu 24.04 LTS x64.
  3. Size: Basic → Regular (SSD) → $6/mo (1 vCPU · 1 GB RAM · 25 GB). $4/mo also works for a hobby setup.
  4. Datacenter: pick the region closest to your target audience. For a NS that fronts the whole internet, New York 3 or Frankfurt 1 are well-connected defaults.
  5. Authentication: SSH Key — paste your ~/.ssh/id_ed25519.pub. Do not use password auth for a public-facing DNS server.
  6. Hostname: ns1 (or ns1.yourdomain.com).
  7. Click Create Droplet. You’ll see a public IPv4 — write it down, we’ll call it 192.0.2.10.

reserve the ip (so it never changes)

NetworkingReserved IPsAssign Reserved IP → select your droplet. From now on the droplet keeps this IP even if you rebuild it.

harden: non-root user + key-only ssh

# from your laptop
ssh root@192.0.2.10

# on the droplet
adduser dns --disabled-password --gecos ""
usermod -aG sudo dns
rsync --archive --chown=dns:dns ~/.ssh /home/dns
echo 'dns ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/dns
# lock root ssh
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
systemctl reload ssh
exit

# reconnect as the non-root user
ssh dns@192.0.2.10

2 · install & systemd

# prerequisites
sudo apt update
sudo apt install -y python3 python3-venv python3-pip git

# clone the repo (or scp dns.py up)
sudo mkdir -p /opt/dns && sudo chown dns:dns /opt/dns
cd /opt/dns
git clone https://github.com/you/dns.git .
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt

# init the database
.venv/bin/python dns.py init-db

# let Python bind port 53 without running as root
sudo setcap 'cap_net_bind_service=+ep' $(readlink -f .venv/bin/python3)

# pin a stable api token (otherwise regenerated on every restart)
TOKEN=$(openssl rand -hex 24)
.venv/bin/python dns.py set-config api_token "$TOKEN"
echo "dashboard token: $TOKEN"   # save it somewhere safe

systemd unit

Create /etc/systemd/system/dns.service:

[Unit]
Description=DNS Ops Console
After=network.target

[Service]
Type=simple
User=dns
WorkingDirectory=/opt/dns
ExecStart=/opt/dns/.venv/bin/python dns.py start \
    --port 53 --api-port 8088 --dot-port 853 --no-doh \
    --log-level INFO
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
# modest hardening
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/opt/dns
PrivateTmp=yes

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now dns
sudo systemctl status dns          # should say active (running)
sudo journalctl -u dns -f          # tail logs in real time
tipSet --no-doh for now. We’ll let Caddy terminate HTTPS on 443 for the dashboard first, then decide whether to expose DoH separately.

3 · firewall

Use DigitalOcean’s Cloud Firewall (easier to reason about than iptables):

  1. NetworkingFirewallsCreate Firewall.
  2. Inbound rules:
typeprotocolportsources
SSHTCP22Your IP only
HTTPTCP80All IPv4 / IPv6 (Caddy ACME)
HTTPSTCP443All IPv4 / IPv6 (dashboard)
DNSTCP53All IPv4 / IPv6
DNSUDP53All IPv4 / IPv6
DoTTCP853All IPv4 / IPv6 (optional)
  1. Apply the firewall to your droplet.

Leave the API port 8088 unopened to the public — Caddy on 443 will be the only public entry point for the dashboard.

4 · https for the dashboard

Caddy auto-provisions Let’s Encrypt certs and handles renewal. One config file, one reload.

sudo apt install -y caddy

Replace /etc/caddy/Caddyfile:

dns.yourdomain.com {
    reverse_proxy 127.0.0.1:8088
    encode zstd gzip

    # optional: lock down to your laptop's IP / office range
    # @allowed remote_ip 203.0.113.0/24
    # handle @allowed { reverse_proxy 127.0.0.1:8088 }
    # respond 403
}
sudo systemctl reload caddy
sudo journalctl -u caddy -f    # watch the ACME challenge succeed

Once the DNS record for dns.yourdomain.com resolves to 192.0.2.10 (set that up in step 6), Caddy will fetch a real certificate on first request. https://dns.yourdomain.com/ then serves the dashboard with browser-trusted TLS.

5 · tls for dot & doh

Caddy’s Let’s Encrypt cert can be reused for DoT/DoH.

CERT_DIR=/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/dns.yourdomain.com
sudo chmod +x /var/lib/caddy/.local/share/caddy
sudo chmod +x $(dirname "$CERT_DIR")

# point dns.py at Caddy-managed cert files
cd /opt/dns
.venv/bin/python dns.py set-config tls_cert_path "$CERT_DIR/dns.yourdomain.com.crt"
.venv/bin/python dns.py set-config tls_key_path  "$CERT_DIR/dns.yourdomain.com.key"

# restart to load the real cert on DoT/DoH listeners
sudo systemctl restart dns

Clients that support DoT (e.g. iOS/Android private DNS, Firefox, systemd-resolved) can now be pointed at dns.yourdomain.com and the cert validates.

6 · cloudflare integration

Three deployment paths, in order of how much traffic you want to trust to one $6 droplet.

path A — cloudflare handles your domain, this server runs a delegated subdomain (recommended)

  1. Buy your domain at any registrar. Add the site to Cloudflare (free plan); point the domain’s nameservers at Cloudflare’s (kai.ns.cloudflare.com / max.ns.cloudflare.com — Cloudflare tells you which two).
  2. In Cloudflare DNS for yourdomain.com, add:
typenamecontentproxy
Ans1192.0.2.10DNS only (grey cloud)
Adns192.0.2.10Proxied (orange cloud) OR DNS only
NSlabns1.yourdomain.comn/a

Now *.lab.yourdomain.com is delegated to your server — Cloudflare handles the apex and everyone else, your box handles the lab zone. In the dashboard, create zone lab.yourdomain.com with --ns ns1.yourdomain.com --admin admin.yourdomain.com, then add the records you want under it.

why this pathYou get Cloudflare’s global anycast + DDoS protection for the main domain, and still get to play with authoritative DNS for a subdomain — the failure blast radius is a single subdomain, not your whole domain.

path B — cloudflare as a secondary (pro plan; requires AXFR)

Cloudflare’s Secondary DNS feature lets them pull your zone via AXFR zone transfer. This server doesn’t implement AXFR yet, so path B isn’t available until that lands. If you add it, Cloudflare gives you near-unlimited redundancy and DDoS protection while your box stays the source of truth.

path C — this server is the only authoritative nameserver

Possible but not recommended for anything other than a lab domain. You’d need a second droplet running a second copy as ns2.yourdomain.com, then register both as nameservers at the registrar. Single-region, no DDoS mitigation, no redundancy if the droplet reboots.

7 · registrar & glue records

If you’re using path A (Cloudflare for the apex), you’re done with the registrar — just point the nameservers at Cloudflare’s.

If you’re using path C (this server is authoritative), you need to register glue records at your registrar so the outside world can find your nameserver IP before resolving the zone it’s inside:

  1. At your registrar (Namecheap, Porkbun, Cloudflare Registrar, Gandi, etc.), find Personal Nameservers / Host Records / Register Nameservers.
  2. Register ns1.yourdomain.com with IP 192.0.2.10.
  3. Register ns2.yourdomain.com with your second droplet’s IP.
  4. Then set the domain’s nameservers to ns1.yourdomain.com and ns2.yourdomain.com.

The registrar publishes an A glue record for each nameserver hostname directly into the TLD zone file (bypassing the chicken-and-egg of “to resolve the zone I need to resolve a name in the zone”).

8 · verify end-to-end

From your laptop:

# does the root-to-tld-to-your-server chain work?
dig +trace www.lab.yourdomain.com A

# ask an external resolver who the NS is for your zone
dig NS lab.yourdomain.com @8.8.8.8

# hit the server directly
dig www.lab.yourdomain.com A @192.0.2.10

# test DoT
kdig @dns.yourdomain.com +tls www.lab.yourdomain.com A

# hit the rest API
curl -H "Authorization: Bearer $TOKEN" https://dns.yourdomain.com/api/stats

Expect the +trace output to terminate with your NS answering authoritatively. If it stops at the TLD nameservers, your registrar-side NS / glue is misconfigured. Propagation can take 10 minutes to 24 hours depending on the TLD.

9 · backups

# nightly sqlite backup kept 7 days locally, pushed to s3/backblaze if you want offsite
sudo crontab -u dns -e
# add:
0 3 * * * sqlite3 /opt/dns/dns.db ".backup '/home/dns/backups/dns-$(date +\%F).db'" \
          && find /home/dns/backups -name 'dns-*.db' -mtime +7 -delete

.backup is a hot-backup that works safely even with WAL mode on and the server actively writing. No downtime needed.

10 · monitoring

  • Service healthsystemctl status dns + journalctl -u dns --since today.
  • External uptime — point UptimeRobot at https://dns.yourdomain.com/ (HTTP check) and at a TCP probe for port 53.
  • Dashboard query log — live tail of every resolved / blocked / forwarded query. Useful for catching amplification probes.
  • Upstream failures counterGET /api/stats exposes upstream_failures. If it spikes, the recursive fallback chain is in trouble.
# quick one-liner: tail only warnings/errors
sudo journalctl -u dns -f --output=cat | grep -E 'WARN|ERROR'

11 · production gotchas

amplification attack attempts

Public port-53 servers routinely get spoofed-source queries from attackers trying to use your server to DoS a third party. Watch top_domains in the dashboard — a single external name resolving 10,000 times in an hour is a signal. Mitigations: enable rate-limiting on the firewall (nftables + hashlimit), or put Cloudflare in front of the zone entirely (path A).

fail2ban for ssh

sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban

caddy can’t get a cert

Usually means port 80 isn’t open (ACME HTTP-01 challenge) or the A record for dns.yourdomain.com isn’t pointing at the droplet yet. Verify with curl -I http://dns.yourdomain.com/ — Caddy should answer.

“address already in use” on port 53

Ubuntu ships systemd-resolved which sometimes listens on 127.0.0.53:53. On a dedicated DNS host, disable its stub listener:

sudo sed -i 's/^#DNSStubListener=yes/DNSStubListener=no/' /etc/systemd/resolved.conf
sudo systemctl restart systemd-resolved

the droplet reboots, server doesn’t come back

Check the systemd unit Restart=on-failure — it handles crashes but not clean exits. If you see Restart=always would be safer for a DNS host, switch it.

my api token keeps rotating

You didn’t run set-config api_token before the service started, or you deleted the config entry. Set it once, then the token survives restarts — confirm with get-config api_token.

domains

namecheap · search & register

find a domain sandbox

checks .com .io .net .dev .app .xyz .co .sh by default — override with a full name (e.g. example.org) to check a single TLD

inbox

messages received at your mailboxes
no messages yet
select a message

mail

smtp config · sent log · email-based auth
mailboxes 0
forwarded via forwardemail.net (free, dns-only)
@
addressforwards tocreated
smtp configuration unset
compose & send
sent emails 0
sent attosubjectstatuserror