overview
zones
| name | type | ttl | created |
|---|
ns1
and add a glue record pointing that hostname to this server’s IP.
records
| name | type | rdata | ttl |
|---|
A / AAAA map a hostname to an IPv4 / IPv6. CNAME
is a hostname alias. MX routes email (needs a priority). TXT
holds arbitrary text — SPF / DKIM / DMARC / verification tokens. NS
delegates a subdomain to other nameservers. Full rdata format reference is in the
docs tab under records.
nameservers
| zone | type | hostname | ttl |
|---|
resolver
| domain | response | added |
|---|
| # | address | port | priority | status |
|---|
logs
| when | actor | action | resource | ip | details |
|---|
users
| id | display name | role | created | last login | actions |
|---|
admin — everything a user has, plus access to this Users tab and the master bearer token. User id
1 is the bootstrap admin and can’t be deleted — prevents lockout.
Changing a user’s password via “change pw” forces them to re-login everywhere. Their DEK (for encryption-at-rest) is derived from the password, so changing the password re-wraps the DEK automatically; no mail is lost.
billing
| id | status | amount / mo | last payment | charge |
|---|
/api/billing/stripe/webhook, and M-Pesa's callback URL (set in your Daraja app) at /api/billing/mpesa/callback. Both must be publicly reachable.
Per-link branding (optional)
| id | amount | description | status | via | created | expires | paid | actions |
|---|
| when | provider | type | user | sig | result |
|---|
paid, active, or past_due. Free users aren’t counted.
expected MRR — sum of
subscription_amount across all subscribers. What you’d bill this month if every charge clears.
paid this month — sum of
subscription_amount across users whose status is paid. The gap between this and expected MRR is your at-risk revenue.
failed tx — users in
past_due or cancelled. past_due keeps them as subscribers (the charge may retry); cancelled removes them from expected MRR.
Everything is computed on demand from the
users table — no separate billing ledger. Populate via the seed-demo-users CLI or wire a real payment processor (Stripe or M-Pesa) to flip subscription_status and stamp last_payment_at automatically.
agent
anthropic_api_key in the config tab before first use.
Read tools:
list_zones, list_records, query_dns, get_health, list_users, list_outbound_queue.
Write tools:
add_record, block_domain — both audit-logged. Destructive actions (delete record, delete user, edit zone) are deliberately NOT exposed yet; ask for them once you've trusted the agent on the safe ones.
Every assistant turn can run up to 8 rounds of tool calls before responding, so a single message can do "list my zones → list records in the one you picked → add a record → verify it's there" in one shot. Conversations auto-save; switch between them from the sidebar.
mail templates
—
config
config.api_token row. Prefer scoped API keys (admin/write/read) for day-to-day use.
docs
quick start
You’re already in the dashboard — here’s the 60-second tour. Nothing to install.
- Authenticate. Click the
tokenbutton (top-right) and paste the API token your operator gave you. It’s stored inlocalStorage; one paste lasts forever per browser. If you ever see a 401 anywhere in the app, the token modal pops automatically — just paste again. - Take the inbox tour. Click
inboxin the left nav. Three panes: folders on the left, message list in the middle, message body on the right. Hit+ Compose(or just pressc) to write a new message; press?anywhere to see all keyboard shortcuts. - Look at your zones. Click
zones— each one is a domain this server is authoritative for. Clickrecordsto see/edit the A / AAAA / CNAME / MX / TXT / NS rows that resolve under each zone. - See live queries. Click
query log— every DNS lookup answered by this server scrolls past in real time. Use it to confirm a record edit landed, or to debug a "why isn’t my domain resolving?" report. - Browse the usecases tab for end-to-end recipes: personal email, ad-blocking, encrypted DNS, weighted routing, etc. Each one names the panels you’ll touch and the gotcha you’ll hit.
j/k walk the list, o/Enter open a thread, e archives, s stars, r/a/f reply · reply-all · forward, / jumps to search, Esc closes. Hit ? to see them all.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 |
| resolver | blocklist (add / bulk import / unblock), upstream resolver chain (priority, add/remove), and cache (stats + flush) |
| logs | live query log (ring-buffer tail, pause/refresh) — plus the admin audit trail in the same tab |
| 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.
usecases
overview
One Python file, one SQLite database, eight realistic things you can run with it. Each section below names the problem, the panels you’ll touch, and the gotcha that’ll bite you.
None of these need a managed service. The droplet you already have is enough. Use the navigation on the left to jump to a scenario.
config → master token, paste it into the dashboard via the token button, then come back. Every API call below assumes you’re authenticated.1 · personal email at your domain
Send and receive mail at you@yourdomain.com without paying Google or Microsoft, with DKIM-signed outbound and a Gmail-style 3-pane inbox.
what you’ll set up
- An MX record pointing your domain at this server —
recordstab, typeMX, targetmail.yourdomain.compriority 10. - A DKIM keypair —
mail → DKIM signing, click generate. Copy the public TXT and publish it underrecordsasdefault._domainkey.yourdomain.com. - SPF + DMARC TXT records at the apex —
v=spf1 a -allandv=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com. - A sending identity —
mail → sending identities, addyou@yourdomain.com, mark default, attach a signature. - A local mailbox for inbound —
mail → mailboxes, add the local part (e.g.you), choose the zone, leave forward-to blank for type=local.
verify it works
# Should answer with your droplet’s MX
dig @8.8.8.8 yourdomain.com MX
# Test SPF/DKIM/DMARC alignment via mail-tester
# compose → send to test-XXXXX@mail-tester.com from the dashboard
Compose from the inbox tab’s + Compose button. Reply / reply-all / forward live at the bottom of any open thread. Drafts autosave every second; attachments dedupe by sha256 on disk.
mail/queue until you can release them.2 · authoritative DNS for a domain
Be the source of truth for yourdomain.com — no Cloudflare, no Route53, no third-party seeing every query.
what you’ll set up
- Create the zone —
zonestab, add zone, primary NSns1.yourdomain.com, adminadmin.yourdomain.com. - Add records —
recordstab. Minimum:@A pointing at your web host,wwwCNAME or A, your MX from use case 1, NS records for the zone itself. - Glue records at the registrar — in your registrar’s DNS panel, set the nameservers for
yourdomain.comtons1.yourdomain.com+ add a glue record mapping that hostname to the droplet IP. Glue is mandatory because the NS record lives inside the zone you’re delegating — chicken-and-egg.
verify it works
# Direct query to your server
dig @your.droplet.ip yourdomain.com A
# Resolution from a public resolver (after registrar propagation)
dig @1.1.1.1 yourdomain.com NS
3 · network ad/tracker blocking
Pi-hole-style ad blocking for every device on your network. Use this server as their resolver, with a curated blocklist of trackers and ad networks.
what you’ll set up
- Import a blocklist —
resolvertab, blocklist section, paste a hosts-format list (StevenBlack’s unified list works well). One domain per line; the server normalizes0.0.0.0 example.comentries. - Configure clients — either point individual devices’ DNS at the droplet, or set the droplet IP as DNS on your router for the whole household.
- Set an upstream forwarder for non-blocked queries — same
resolvertab, upstream section, add1.1.1.1or9.9.9.9. Anything not in your zones or your blocklist forwards there.
verify it works
dig @your.droplet.ip doubleclick.net # expect NXDOMAIN or 0.0.0.0
dig @your.droplet.ip wikipedia.org # expect normal answer (forwarded)
Check the logs tab for what’s being blocked vs. allowed in real time. The resolver tab’s cache section shows how much of recent traffic is being answered from cache vs. upstream.
4 · encrypted personal resolver
Stop your ISP from seeing every domain you visit. Run DoT (DNS-over-TLS) and DoH (DNS-over-HTTPS) for your phone and laptop.
what you’ll set up
- TLS certificate — if your dashboard already serves HTTPS via Caddy, the same cert works for DoH. For DoT (port 853) the server self-generates a cert if
tls_cert_path/tls_key_patharen’t set inconfig. - Open ports — 853/tcp (DoT) and 443/tcp (DoH). Keep them open in the firewall.
- iOS / Android profile — install a DNS-over-TLS profile pointing at
dns.yourdomain.com:853. iOS settings → General → VPN & Device Management. - Firefox / Chrome — set DoH server to
https://dns.yourdomain.com/dns-queryin browser network settings.
verify it works
# DoT
kdig @dns.yourdomain.com -p 853 +tls example.com
# DoH
curl -H 'accept: application/dns-message' 'https://dns.yourdomain.com/dns-query?dns=AAABAAABAAAAAAAAB2V4YW1wbGUDY29tAAABAAE'
Once running, your phone’s every DNS lookup is encrypted between device and droplet. Combine with use case 3 for ad-blocked encrypted DNS.
5 · catch-all email forwarding
Give every signup a unique address — amazon@yourdomain.com, github@yourdomain.com — all forwarded to your real inbox. Spammer leaks an alias? Disable just that one without churning your real address.
what you’ll set up
- An MX record as in use case 1, plus SPF.
- Forwarding mailboxes —
mail → mailboxes, set type toforwardand target your real inbox. Create as many as you need. - (Optional) A wildcard alias via
local_part = '*'for true catch-all.
verify it works
Send a test message to some-new-alias@yourdomain.com from any external account. It should arrive at your real inbox within seconds. The forwarding path goes through MailSender so it’s DKIM-signed by your domain — the recipient sees a clean SPF + DKIM pass.
mail → filters) let you star, archive, or drop forwarded mail by sender or subject before it hits your real inbox — useful when an alias starts leaking.6 · weighted / geo routing
Send 70% of traffic to one origin, 30% to a canary. Or send European visitors to a Frankfurt IP and US visitors to NYC. The query router supports both with no extra services.
what you’ll set up
- Multiple A records for the same name in
records. Each gets aweightfield — the router picks among them weighted random. - Geo overrides via the
route_overridesconfig — map a country code or subnet to a specific record set. Documented underdocs → weighted / geo routing.
verify it works
# Repeated queries should hit different IPs in the configured ratio
for i in {1..20}; do dig @your.droplet.ip +short www.yourdomain.com; done | sort | uniq -c
7 · wildcard dev environments
Spin up feature-branch.dev.yourdomain.com on demand without touching DNS for every preview deploy. Useful for staging environments, ngrok-style tunnels, or per-PR previews.
what you’ll set up
- Wildcard A record — in
records, name*.dev, type A, target your dev box’s IP. - Wildcard TLS — if you need HTTPS on those subdomains, run Caddy on the dev box and let it handle ACME DNS-01 challenges via this server’s API.
docs → rest apicovers the record-create endpoints Caddy needs.
verify it works
dig @your.droplet.ip anything.dev.yourdomain.com A # should resolve
dig @your.droplet.ip another.dev.yourdomain.com A # same answer
8 · host friends’ domains
Five friends, five domains, one droplet. Each gets their own zone with separate records, no cross-contamination, all served by the same nameserver.
what you’ll set up
- One zone per domain —
zonestab, repeat use case 2’s steps for each. - Same nameserver name for all of them — e.g.
ns1.yourdomain.comservesfriend1.example,friend2.example, etc. Each domain’s registrar points its NS atns1.yourdomain.com; only the glue record (which lives at your domain’s registrar) needs to be set up once. - (Optional) Scoped API keys — under
config, mint a write-scoped key per friend so they can self-serve record edits via the API without seeing each other’s zones (current implementation is single-tenant; this part needs the multi-mailbox extension).
verify it works
# Each domain resolves independently
dig @your.droplet.ip friend1.example A
dig @your.droplet.ip friend2.example A
The query router walks the zone tree per request, so one friend’s zone editing has zero impact on another’s answers.
domains
.com .io .net .dev .app .xyz .co .sh by default — override with a full name (e.g. example.org) to check a single TLD
| name | records | delegated | created | actions |
|---|
ns1 is the primary nameserver for the zone’s SOA. admin is the rname field (e.g. hostmaster.yourdomain.com). Both can be edited later under the zones tab.
inbox
| address | forwards to | created |
|---|
| address | display name | default | signature | actions |
|---|
move_to, mark_read, star, drop.
match keys: from (exact), to_contains, subject_contains.
| priority | match | action | enabled | actions |
|---|
| recipient | state | send_next_at | attempts | last error |
|---|
| sent at | to | subject | status | error |
|---|