>zone//live
⌘K 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
all zones 0
name type ttl created
what’s a zone?
A zone is a domain this server is authoritative for. It carries an SOA (Start of Authority) plus all the records (A/AAAA/CNAME/MX/TXT/…) that resolve under it. Create a zone here first, then add records on the Records tab. For real internet traffic to resolve via this server, set the registrar’s nameservers to your ns1 and add a glue record pointing that hostname to this server’s IP.

records

per-zone resource records
records
name type rdata ttl
record types cheat sheet
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

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

resolver

blocklist · upstream fallback chain · cache
blocklist — add / import
blocklist — blocked domains 0
domain response added
upstream — add resolver
upstream — resolvers 0
# address port priority status
cache — stats
entries
0
capacity
hit rate
0%
misses
0
cache — actions
invalidates every entry, forces fresh upstream lookups

logs

live query stream · admin audit trail
query log 0

users

per-tenant mailbox + zone owners · admin only
create user
all users 0
idemaildisplay namerolecreatedlast loginactions
roles
user — owns their own mailboxes, identities, DKIM keys, zones, and filters. Can’t see other users’ data.
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

subscription state · revenue · admin only
overview
subscriptions 0
id email status amount / mo last payment charge
payment methods
Tip: point Stripe's webhook at /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.
link analytics
webhook events 0
when provider type user sig result
Every inbound Stripe webhook / M-Pesa callback lands here, including ones that fail signature check. The payload column expands to the full event body for forensics.
about these numbers
subscribers — users whose status is 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

chat that can inspect + mutate server state · admin only
new chat
about
The agent uses the Anthropic API. Set 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

subject + body of every transactional email this server sends · preview · test-send · admin only
select a template
preview
Edit sample variables above, then click preview.
subject
text

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
type a key to see its current value
entries 0

docs

every feature, every flag

quick start

You’re already in the dashboard — here’s the 60-second tour. Nothing to install.

  1. Authenticate. Click the token button (top-right) and paste the API token your operator gave you. It’s stored in localStorage; one paste lasts forever per browser. If you ever see a 401 anywhere in the app, the token modal pops automatically — just paste again.
  2. Take the inbox tour. Click inbox in the left nav. Three panes: folders on the left, message list in the middle, message body on the right. Hit + Compose (or just press c) to write a new message; press ? anywhere to see all keyboard shortcuts.
  3. Look at your zones. Click zones — each one is a domain this server is authoritative for. Click records to see/edit the A / AAAA / CNAME / MX / TXT / NS rows that resolve under each zone.
  4. 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.
  5. 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.
tipThe inbox responds to Gmail-style keys. 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.

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
resolverblocklist (add / bulk import / unblock), upstream resolver chain (priority, add/remove), and cache (stats + flush)
logslive query log (ring-buffer tail, pause/refresh) — plus the admin audit trail in the same tab
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.

usecases

what this server lets you actually do

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.

before you startSet the master token under 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

  1. An MX record pointing your domain at this server — records tab, type MX, target mail.yourdomain.com priority 10.
  2. A DKIM keypairmail → DKIM signing, click generate. Copy the public TXT and publish it under records as default._domainkey.yourdomain.com.
  3. SPF + DMARC TXT records at the apex — v=spf1 a -all and v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com.
  4. A sending identitymail → sending identities, add you@yourdomain.com, mark default, attach a signature.
  5. 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.

port 25 outboundDigitalOcean blocks outbound TCP/25 by default for new accounts. File a support ticket explaining your use case — turnaround is 1–3 days. Until then queued sends accumulate visible in 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

  1. Create the zonezones tab, add zone, primary NS ns1.yourdomain.com, admin admin.yourdomain.com.
  2. Add recordsrecords tab. Minimum: @ A pointing at your web host, www CNAME or A, your MX from use case 1, NS records for the zone itself.
  3. Glue records at the registrar — in your registrar’s DNS panel, set the nameservers for yourdomain.com to ns1.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
SOA serialEvery record edit auto-bumps the zone’s SOA serial — secondary nameservers (if any) pick up the change on their next refresh. No manual step.

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

  1. Import a blocklistresolver tab, blocklist section, paste a hosts-format list (StevenBlack’s unified list works well). One domain per line; the server normalizes 0.0.0.0 example.com entries.
  2. Configure clients — either point individual devices’ DNS at the droplet, or set the droplet IP as DNS on your router for the whole household.
  3. Set an upstream forwarder for non-blocked queries — same resolver tab, upstream section, add 1.1.1.1 or 9.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

  1. 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_path aren’t set in config.
  2. Open ports — 853/tcp (DoT) and 443/tcp (DoH). Keep them open in the firewall.
  3. iOS / Android profile — install a DNS-over-TLS profile pointing at dns.yourdomain.com:853. iOS settings → General → VPN & Device Management.
  4. Firefox / Chrome — set DoH server to https://dns.yourdomain.com/dns-query in 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

  1. An MX record as in use case 1, plus SPF.
  2. Forwarding mailboxesmail → mailboxes, set type to forward and target your real inbox. Create as many as you need.
  3. (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.

tipFilters (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

  1. Multiple A records for the same name in records. Each gets a weight field — the router picks among them weighted random.
  2. Geo overrides via the route_overrides config — map a country code or subnet to a specific record set. Documented under docs → 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

  1. Wildcard A record — in records, name *.dev, type A, target your dev box’s IP.
  2. 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 api covers 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

  1. One zone per domainzones tab, repeat use case 2’s steps for each.
  2. Same nameserver name for all of them — e.g. ns1.yourdomain.com serves friend1.example, friend2.example, etc. Each domain’s registrar points its NS at ns1.yourdomain.com; only the glue record (which lives at your domain’s registrar) needs to be set up once.
  3. (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

namecheap · search & register · bring-your-own
buy 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
my domains 0
every zone you control on this server. A zone delegated here means real internet traffic resolves through it; zones marked local only answer only to direct queries (no registrar glue yet).
namerecordsdelegatedcreatedactions
add existing domain
you already own this domain elsewhere (e.g. Namecheap / Google Domains). Add the zone here, create records, then switch the domain’s nameservers at the registrar to point at this server. Glue records need to be set once at the registrar — docs → deploy covers it.
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

messages received at your mailboxes
loading…
select a message

mail

smtp config · sent log · email-based auth
mailboxes 0
forwarded via forwardemail.net (free, dns-only)
@
addressforwards tocreated
sending identities 0
addressdisplay namedefaultsignatureactions
DKIM signing
loading…
filters 0
actions: move_to, mark_read, star, drop. match keys: from (exact), to_contains, subject_contains.
prioritymatchactionenabledactions
smtp configuration unset
outbound queue 0
scheduled sends and pending retries. cancel scheduled rows; force-retry temp-failures.
recipientstatesend_next_atattemptslast error
sent emails 0
sent attosubjectstatuserror