null53 is a lightweight, zero-dependency DNS-over-HTTPS (DoH) proxy written in Go. It intercepts local port 53 queries (UDP & TCP), tunnels them through encrypted HTTPS upstream resolvers, accelerates repeated requests with an in-memory LRU cache, and nullifies ad/tracking queries with an allowlist & blocklist txt files.
Local Client (Browser/OS)
│
[Port 53/1053]
▼
┌─────────┐
│ null53 │
└───┬─┬───┘
│ │
│ ├── Blocked Domain ──► Return NXDOMAIN (0ms, null route)
│ │
│ ├── Cache Hit ───────► Return cached wire bytes (TTL decremented)
│ │
│ └── Cache Miss ──────► HTTPS POST (RFC 8484) ──► Upstream DoH
│ (Cloudflare/Google)
└─────────────────────────────────────────────────────────────
- Port 53 Interceptor: Listens locally on port 53 (or unprivileged 1053 for testing) across both UDP and TCP.
- Null Routing: Resolves ads, telemetry, and tracking domains directly to
NXDOMAIN(null response) with zero WAN overhead. - Encrypted Egress: Upstream communication uses standard HTTPS (port 443), stopping ISP eavesdropping and DNS spoofing.
- Failover Resilient: Automatically fails over between multiple DoH endpoints (Cloudflare, Google, Quad9) if an upstream fails.
- Adaptive LRU Cache: Automatically decays record TTLs based on elapsed time and evicts least-recently-used records when full.
go build -o null53 ../null53 --config config.example.yaml --listen 127.0.0.1:1053output:
level=INFO msg="blocklist loaded" domains=10 source=./blocklist.txt
level=INFO msg="allowlist loaded" domains=0 source=./allowlist.txt
level=INFO msg="null53 started" listen=127.0.0.1:1053 upstreams="[https://cloudflare-dns.com/dns-query https://dns.google/dns-query]"
Normal resolution (encrypted DoH):
dig @127.0.0.1 -p 1053 example.com +shortBlocked domain (null / NXDOMAIN):
dig @127.0.0.1 -p 1053 doubleclick.netreturns status: NXDOMAIN in 0ms without hitting the internet.
TCP framing test:
dig +tcp @127.0.0.1 -p 1053 example.com +shortCache hit verification: send two identical queries back-to-back:
# Query 1 (Cache miss):
msg=query proto=udp name=example.com. type=TypeA result=upstream latency=95ms
# Query 2 (Cache hit):
msg=query proto=udp name=example.com. type=TypeA result=cache-hit latency=15µs
^C to terminate, then, clean query metrics will be displayed:
msg="shutdown complete" total_queries=4 cache_hits=1 blocked=1 upstream=2 errors=0
listen: "127.0.0.1:1053"
upstream:
urls:
- "https://cloudflare-dns.com/dns-query"
- "https://dns.google/dns-query"
timeout: 5s
cache:
max_entries: 10000
blocklist:
path: "./blocklist.txt" # local file or remote https url
refresh_interval: 24h
allowlist:
path: "./allowlist.txt" # overrides blocklist
log:
level: "info" # debug, info, warn, error| Flag | Description | Example |
|---|---|---|
--config |
Path to YAML config | ./null53 --config config.yaml |
--listen |
Listen address/port | ./null53 --listen 127.0.0.1:53 |
--upstream |
Custom DoH upstream | ./null53 --upstream https://dns.quad9.net/dns-query |
--blocklist |
Local path or remote URL | ./null53 --blocklist ./blocklist.txt |
--allowlist |
Local path or remote URL | ./null53 --allowlist ./allowlist.txt |
To run null53 without sudo, assign the CAP_NET_BIND_SERVICE capability to the binary:
sudo setcap cap_net_bind_service=+ep ./null53
./null53 --config config.example.yaml --listen 127.0.0.1:53Then point your local resolver to 127.0.0.1 in /etc/resolv.conf.