Self-hosted, ad-blocking DNS resolver that only speaks encrypted DNS — DNS-over-TLS (DoT) and DNS-over-HTTPS (DoH).
Point your router or devices at it instead of your ISP's resolver. Ads and trackers get dropped before they resolve, and the queries that do go upstream are encrypted end to end.
Written in Rust: a single stripped binary, no runtime, no GC, no interpreter. It idles at a few MB of RAM and answers cache hits from cached wire bytes, so it's happy on a Raspberry Pi, an old NUC, or a free serverless tier.
- DoT on 853 and DoH on 443, both TLS-terminated by the resolver itself. Nothing listens on plaintext port 53.
- Blocklists fetched over HTTPS on a schedule you set. Hosts-file format,
plain domain-per-line, and Adblock Plus network rules (
||domain^) all work — point it at StevenBlack/hosts and you're done. - Whitelist of exact domains that override every blocklist, re-applied on every refresh so a background update can't re-block something you carved out.
- Static hosts —
nas.home = 192.168.1.10style overrides, answered locally, never forwarded upstream. - Block mode: NXDOMAIN, or sinkhole to an IP you pick (Pi-hole style).
- Fast cache with a byte budget instead of an entry count, so tiny NXDOMAIN answers and fat TXT records each use only what they need. Failures (NXDOMAIN / NODATA) get cached too, when upstream says how long that's safe.
- Multiple upstreams: try them in order, hedge slow requests after 25 ms, or race all of them. Duplicate in-flight queries collapse into one request.
- Hot config reload — edit
config.tomland it picks up the change without restarting or dropping connections.
docker run -d \
--name dns-rs \
-p 853:853 \
-p 443:443 \
-v /path/to/config.toml:/etc/dns-rs/config.toml:ro \
-v /path/to/fullchain.pem:/etc/dns-rs/fullchain.pem:ro \
-v /path/to/privkey.pem:/etc/dns-rs/privkey.pem:ro \
ghcr.io/legitcamper/dns-rs:latestStart from config.example.toml — every option is commented. Shape of it:
[server] # bind address, DoT/DoH ports, TLS cert/key paths
[blocking] # nxdomain or sinkhole, and the sinkhole IP
[blocklists] # URLs to fetch, refresh interval
[whitelist] # domains blocklists may never block
[upstream] # DoT/DoH upstream resolvers, sequential or race
[static_hosts] # your own domain -> IP overrides
[cache] # enabled + total byte budgetThe image runs as an unprivileged user with cap_net_bind_service on the
binary, so binding 853/443 needs no --privileged and no -u root. Your
config's tls_cert/tls_key should point at the paths you mounted inside the
container. Tags: :latest, :<git-sha>, :vX.Y.Z.
You need a TLS certificate: self-signed is fine if you can trust it on your clients, or use a DNS-validated ACME cert if the resolver has a public name.
cargo build --release
./target/release/dns-rs --config /path/to/config.tomlBinding 853/443 without root needs cap_net_bind_service on the binary, or
just use high ports in config.toml.
The server can expose plaintext internal listeners for platforms that terminate
TLS at the edge. serverless selects environment-variable configuration;
certless selects plaintext inbound transports; dot and doh independently
select protocols. Outbound queries remain encrypted.
The published serverless image enables all four features and listens on
0.0.0.0:8853 for DNS-over-TCP and 0.0.0.0:${PORT:-8053} for HTTP DoH:
docker run --rm -p 8853:8853 -p 8053:8053 \
ghcr.io/legitcamper/dns-rs-serverless:latestDo not expose these listeners directly to an untrusted network. They are only safe behind a TLS-terminating proxy.
fly.toml maps public DoH 443 and DoT 853 to those internal listeners, uses
Fly-managed certificates, and keeps one Machine running to avoid DNS cold
starts:
fly launch --no-deploy
fly certs add dns.example.com
fly deploySet your Fly app name and primary region during fly launch; the checked-in config
intentionally omits account-specific values. Clients use
https://dns.example.com/dns-query for DoH and dns.example.com:853 for DoT.
GET /healthz checks HTTP liveness. GET /readyz sends a root NS query through
the configured upstream strategy, bypassing local cache and blocklists. It
returns HTTP 200 for a valid root NS answer, or 503 for DNS errors, malformed
answers, or upstream failure (bounded by the normal five-second query timeout).
Both endpoints use the DoH listener: HTTPS in the default build, HTTP behind
platform TLS in the certless build. DoT-only builds have no HTTP endpoints.
Fly checks /readyz every 15 seconds with a six-second timeout and a 30-second
startup grace period. Failed checks remove the Machine from DoH routing;
passing checks restore it. DoT retains a TCP check on port 8853, which detects
listener reachability only. Fly service HTTP checks target their service's
internal port, so the DoT service cannot reuse port 8053's readiness endpoint.
Existing client connections are not migrated by routing checks.
Run at least two Machines (fly scale count 2) to have a healthy failover target.
Inspect checks with fly checks list. Fly health checks do not restart
Machines; the configured on-failure policy retries process crashes up to
10 times. For a running but stuck Machine, use fly machine restart <machine-id>.
Automatic restarts on failed checks require an external watchdog; upstream
outages alone should not trigger restart loops. See
Fly health checks.
Environment variables
| Variable | Default |
|---|---|
PORT / DNSRS_DOH_PORT |
8053 (PORT wins) |
DNSRS_DOT_PORT |
8853 |
DNSRS_BIND_ADDRESS |
0.0.0.0 |
DNSRS_TLS_CERT, DNSRS_TLS_KEY |
required only without certless |
DNSRS_UPSTREAM_URLS |
https://cloudflare-dns.com/dns-query |
DNSRS_UPSTREAM_STRATEGY |
hedged (sequential, hedged, or race) |
DNSRS_BLOCKLIST_URLS, DNSRS_BLOCKLIST_DOMAINS |
empty comma-separated lists |
DNSRS_WHITELIST_URLS, DNSRS_WHITELIST_DOMAINS |
empty comma-separated lists |
DNSRS_BLOCKLIST_REFRESH_SECS, DNSRS_WHITELIST_REFRESH_SECS |
43200 |
DNSRS_BLOCK_MODE, DNSRS_SINKHOLE_IP |
nxdomain, 0.0.0.0 |
DNSRS_STATIC_HOSTS |
empty; format name=ip,name2=ip2 |
DNSRS_CACHE_ENABLED |
true |
DNSRS_CACHE_MAX_SIZE_BYTES |
¼ of cgroup memory, clamped to 1–64 MiB; ceiling 1 GiB |
DNSRS_DEFAULT_TTL |
300 |
Build the Fly profile:
docker build \
--build-arg 'CARGO_ARGS=--no-default-features --features serverless,certless,dot,doh' \
-t dns-rs-serverless .A DoH-only platform profile (for example Azure) uses
--no-default-features --features serverless,certless,doh.
- No plaintext DNS on port 53. Devices that only speak classic DNS need a
client-side proxy (
stubby,dnscrypt-proxy) in front of this. - No DNSSEC validation.
- No IPv6 in synthesized answers — static hosts and sinkhole mode produce
Arecords only.AAAAqueries against those names get NOERROR/NODATA, which still blocks them, just without a v6 sinkhole address. - No ACLs or auth. Anything that can reach the ports can query it. Firewall it like any other internal service.
- Single instance, no clustering. For redundancy, run two independent instances behind DNS-level failover, not two sharing state.
- This is a home-network / single-box resolver, not a BIND/Unbound/CoreDNS replacement at scale. It has not been fuzzed, load-tested, or security reviewed by anyone but its author.
The Fly configuration uses hedged upstreams: a slow primary gets a 25 ms
head start before the next resolver joins. race starts every resolver at
once, at the cost of more upstream traffic. These settings affect cache misses,
not the network round trip between your device and this server.
Cache hits in the final 10% of TTL already trigger a background refresh. A one-second timer also checks the 1024 most recent entries, refreshing repeatedly used names in the final 5-10% of TTL (staggered per name). Names unused for five minutes stop refreshing. Both paths share four refresh slots, in-flight query deduplication, and failure backoff of roughly 1-16 seconds. Original queries, including EDNS options and DNSSEC flags, are retained within the cache byte budget; this reduces entry capacity slightly. TTLs are never extended without an upstream answer. Expired answers remain usable for the existing 30-second stale window with TTL zero.
Expired cache entries are swept in batches of 256 with yields between batches. Blocklist refresh merges run on a blocking worker, one rebuild at a time. Dead upstream DoT connections are replaced by maintenance with bounded retry backoff. Full cache flushes are unnecessary and cause cold misses.
For a short timing capture, set
RUST_LOG=dns_rs::dns::handler=debug,dns_rs::dns::cache=debug.
DNS query complete logs path (fresh, stale, miss, static, blocked)
and elapsed_us; upstream query complete logs upstream wait separately.
These are server processing times, excluding inbound TLS setup and network
transit. Debug logging itself adds overhead; benchmark with RUST_LOG=warn.
Inbound DoT connections still close after 120 seconds without a new query. Client persistent-connection mode can therefore include occasional reconnects.
cargo test # unit + integration tests, no network required
cargo clippy --all-targetsTests use fake upstreams (no real network/TLS) to exercise fallback, race,
caching, and negative-caching behavior — see dns::test_support.