overview
zones
| name | type | ttl | created |
|---|
records
| name | type | rdata | ttl |
|---|
nameservers
| zone | type | hostname | ttl |
|---|
blocklist
| domain | response | added |
|---|
upstream
| # | address | port | priority | status |
|---|
cache
query log
config
config.api_token row. Prefer scoped API keys (admin/write/read) for day-to-day use.
docs
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.
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.
| protocol | transport | default port | notes |
|---|---|---|---|
| UDP | datagram | 53 | Primary. Responses >512 bytes set TC flag; client retries over TCP. |
| TCP | stream | 53 | Length-prefixed (2-byte) per RFC 1035. |
| DoT | TLS over TCP | 853 | Self-signed cert auto-generated; override via tls_cert_path / tls_key_path. |
| DoH | HTTPS POST | 443 | POST /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.
| type | rdata format | example |
|---|---|---|
| A | dotted IPv4 | 93.184.216.34 |
| AAAA | IPv6 | 2606:2800:220:1:248:1893:25c8:1946 |
| CNAME | target domain | www.example.com |
| MX | priority host | 10 mail.example.com |
| NS | nameserver host | ns1.example.com |
| TXT | free-form string | v=spf1 mx -all |
| SOA | mname rname serial refresh retry expire minimum | ns1.example.com admin.example.com 1 3600 900 604800 86400 |
| SRV | priority weight port target | 10 60 5060 sip.example.com |
| CAA | flags tag value | 0 issue letsencrypt.org |
| PTR | target domain | host.example.com |
| SPF | same as TXT | v=spf1 ip4:1.2.3.0/24 -all |
| NAPTR | order preference flags service regexp replacement | 100 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.
serialauto-bumps on every non-SOA record change. - The SOA column renders as
mname rname · serial Nfor 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
- Exact —
ads.example.commatches that single FQDN. - Wildcard —
*.tracker.example.commatches any subdomain oftracker.example.com(but nottracker.example.comitself — add that separately if needed).
response types
A/AAAAqueries for a blocked name return a synthetic0.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: Google8.8.8.8, Cloudflare1.1.1.1, Quad99.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_failuresin/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
| command | description |
|---|---|
init-db | create schema, seed default upstream resolvers |
start | run all listeners; flags: --port, --api-port, --doh-port, --dot-port, --no-doh, --no-dot, --log-level |
zones
| command | description |
|---|---|
add-zone NAME | flags: --ns, --admin (creates SOA record when both provided) |
list-zones | list every zone |
delete-zone NAME | delete zone; cascades to records |
records
| command | description |
|---|---|
add-record ZONE NAME TYPE RDATA | flags: --ttl, --priority, --weight, --port |
list-records ZONE | list records in zone |
delete-record ID | delete record by primary key |
proxy
| command | description |
|---|---|
add-upstream ADDR | flags: --port (53), --priority (0) |
list-upstream | list resolvers in priority order |
block DOMAIN | flag: --response nxdomain|zero |
unblock DOMAIN | remove from blocklist |
import-blocklist FILE | bulk import, one domain per line |
list-blocked | list blocked domains |
flush-cache | invalidate every cache entry |
config
| command | description |
|---|---|
set-config KEY VALUE | upsert a key/value in the config table |
get-config KEY | read a config value |
zones in bulk
| command | description |
|---|---|
import-zone ZONE FILE [--replace] | ingest a BIND zone file; - reads stdin |
export-zone ZONE | write BIND zone file text to stdout |
dnssec
| command | description |
|---|---|
dnssec-enable ZONE | generate ECDSA P-256 key, publish DNSKEY, print DS |
dnssec-disable ZONE | revoke signing |
dnssec-ds ZONE | emit DS record for publication at the parent |
dnssec-status ZONE | show 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.
| method | path | description |
|---|---|---|
GET | /api/stats | live server counters + uptime + top domains |
GET | /api/zones | list zones |
POST | /api/zones | create zone {name, ns?, admin?} |
DELETE | /api/zones/:name | delete zone and all its records |
GET | /api/zones/:name/records | list records for a zone |
POST | /api/zones/:name/records | add record {name, type, rdata, ttl?} |
PUT | /api/records/:id | update record fields |
DELETE | /api/records/:id | delete record by id |
GET | /api/blocklist | list blocked |
POST | /api/blocklist | block one {domain} |
POST | /api/blocklist/import | bulk {domains: [...]} |
DELETE | /api/blocklist/:domain | unblock |
GET | /api/upstream | list resolvers |
POST | /api/upstream | add resolver {address, port?, priority?} |
DELETE | /api/upstream/:id | remove resolver |
GET | /api/cache/stats | cache metrics |
DELETE | /api/cache | flush entire cache |
GET | /api/logs?limit=N | tail of recent queries (default 100) |
GET | /api/config | all config keys |
PUT | /api/config/:key | update value {value} |
POST | /api/zones/:name/import | ingest BIND zone file (text/plain body); ?replace=1 wipes first |
GET | /api/zones/:name/export | serialise as BIND zone file |
GET | /api/zones/:name/dnssec | DNSSEC status + DS |
POST | /api/zones/:name/dnssec | generate zone key, return DS |
DELETE | /api/zones/:name/dnssec | revoke signing |
GET | /api/domains/status | Namecheap auth state + sandbox/production mode |
GET | /api/domains/search?q=X | check TLD availability + pricing |
POST | /api/domains/register | register a domain (requires contact block) |
GET | /metrics | Prometheus 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
| tab | what it shows |
|---|---|
| overview | live stats (total, hit rate, blocked, uptime), sparklines, streaming query log, top domains |
| zones | zone CRUD with created date, type, TTL |
| records | per-zone record table with color-coded type chips and an add-record modal |
| nameservers | NS & SOA records across every zone in one authority map |
| blocklist | add single / bulk import / unblock |
| upstream | resolver chain with priority, add/remove |
| cache | size, capacity, hit rate, misses, flush action |
| query log | full ring-buffer tail (newest first), pause/refresh controls |
| config | read/write the server config key-value table |
| domains | Namecheap search & register (set namecheap_* config first) |
| deploy | step-by-step DigitalOcean + Cloudflare production playbook |
| docs | this page |
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.
| key | default | effect |
|---|---|---|
api_token | random on startup | persistent bearer token for REST / dashboard |
tls_cert_path | auto-generated self-signed | PEM cert used for DoT & DoH |
tls_key_path | auto-generated self-signed | PEM private key for DoT & DoH |
rate_limit_per_sec | 30 | per-IP token refill rate; 0 disables |
rate_limit_burst | 60 | per-IP bucket capacity |
query_log_size | 10000 | max 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_mode | sandbox | sandbox 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
- Client sends query with EDNS OPT record and the
DOflag set. - Authoritative response is built normally (A/AAAA/MX/NS answers).
- 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).
- For NXDOMAIN, an NSEC proof of denial is added to the authority section and signed.
- The DNSKEY record lives at the zone apex — queryable with
dig DNSKEY example.com.
REST
GET | /api/zones/:name/dnssec | status + DS |
POST | /api/zones/:name/dnssec | generate key, return DS |
DELETE | /api/zones/:name/dnssec | revoke 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
- Drop records where
enabled = 0(manual failover toggle). - If any record carries a
geotag, prefer those matching the client’s country (from GeoIP lookup); fall back to records withgeounset ordefault. - 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_totaldns_authoritative_responses_totaldns_cache_hits_total,dns_cache_misses_total,dns_cache_entries,dns_cache_hit_ratedns_blocked_queries_totaldns_upstream_failures_totaldns_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
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) │
└─────────────────────────────────────────────────────► │
1 · digitalocean droplet
create
- DigitalOcean → Create → Droplets.
- Image: Ubuntu 24.04 LTS x64.
- Size: Basic → Regular (SSD) → $6/mo (1 vCPU · 1 GB RAM · 25 GB). $4/mo also works for a hobby setup.
- 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.
- Authentication: SSH Key — paste your
~/.ssh/id_ed25519.pub. Do not use password auth for a public-facing DNS server. - Hostname:
ns1(orns1.yourdomain.com). - 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)
Networking → Reserved IPs → Assign 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
--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):
- Networking → Firewalls → Create Firewall.
- Inbound rules:
| type | protocol | port | sources |
|---|---|---|---|
| SSH | TCP | 22 | Your IP only |
| HTTP | TCP | 80 | All IPv4 / IPv6 (Caddy ACME) |
| HTTPS | TCP | 443 | All IPv4 / IPv6 (dashboard) |
| DNS | TCP | 53 | All IPv4 / IPv6 |
| DNS | UDP | 53 | All IPv4 / IPv6 |
| DoT | TCP | 853 | All IPv4 / IPv6 (optional) |
- 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)
- 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). - In Cloudflare DNS for
yourdomain.com, add:
| type | name | content | proxy |
|---|---|---|---|
| A | ns1 | 192.0.2.10 | DNS only (grey cloud) |
| A | dns | 192.0.2.10 | Proxied (orange cloud) OR DNS only |
| NS | lab | ns1.yourdomain.com | n/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.
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:
- At your registrar (Namecheap, Porkbun, Cloudflare Registrar, Gandi, etc.), find Personal Nameservers / Host Records / Register Nameservers.
- Register
ns1.yourdomain.comwith IP192.0.2.10. - Register
ns2.yourdomain.comwith your second droplet’s IP. - Then set the domain’s nameservers to
ns1.yourdomain.comandns2.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 health —
systemctl 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 counter —
GET /api/statsexposesupstream_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
find a domain sandbox
.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
| address | forwards to | created |
|---|
| sent at | to | subject | status | error |
|---|