Guide
Encrypted DNS and upstreams
Pick the servers TelltaleDNS asks, keep those lookups private on the way out, and let your phones and laptops reach TelltaleDNS over an encrypted connection too.
- Choose your upstreams
- Encrypt lookups to your upstreams
- Offer encrypted DNS to your devices
- Send a domain to another server
- Resolve without a forwarder
- Check DNSSEC signatures
- When an upstream fails
- Troubleshooting
Words used here: an upstream is a server TelltaleDNS asks when it doesn't have the answer. An upstream group is a named set of upstreams with a strategy. Queries that match no route use the group named default.
Choose your upstreams
Start from a preset instead of looking up addresses. The catalog covers every Pi-hole preset plus the common encrypted resolvers, and a nightly job checks that every entry still answers.
telltale presets list # Cloudflare, Google, Quad9, AdGuard, Mullvad, Control D, NextDNS, ...
telltale presets show quad9 --proto tls,https --group default >> telltale.toml
telltale presets show nextdns --param profile=abc123 # templated presets need your account ID
show prints explicit [[upstream]] entries plus a fastest group. Your config always lists exactly what's used and never depends on the catalog.
A group's strategy is one of failover, round_robin, weighted, fastest, or parallel.
Web UI
- Open the Upstreams page. It holds both servers and upstream groups.
- Add or edit an entry. Simple view shows the common fields; switch to Advanced for timeouts and TLS names.
- Test it asks the draft upstream for the root's NS records from this node, and shows the answer and the time, or the error (a bad URL, a TLS name that doesn't match, a timeout).
- Check shows what the change will do and any warnings, such as a group that would lose its last member. Nothing is saved until Apply.
The Manage table at the bottom says where each entry comes from: the config file, added here, or an override. See Web UI on a config file.
Config file
[[upstream]]
name = "cloudflare"
url = "udp://1.1.1.1"
[[upstream]]
name = "quad9"
url = "udp://9.9.9.9"
[[upstream_group]]
name = "default" # queries that match no route use this group
members = ["cloudflare", "quad9"]
strategy = "fastest" # failover | round_robin | weighted | fastest | parallel
Check it with telltale config check, then reload. Upstreams and groups apply at once. Every key is in the configuration reference.
API
PUT or DELETE on /api/v1/upstreams/{name} and /api/v1/upstream-groups/{name}, with ?dryRun=true to preview. The body has the same fields as the TOML section. POST /api/v1/checks/upstream tests a draft, like Test it.
PUT /api/v1/upstreams/quad9-dot?dryRun=true
{"url": "tls://9.9.9.9", "tls_server_name": "dns.quad9.net"}
Encrypt lookups to your upstreams
Change an upstream's URL scheme to encrypt the lookups TelltaleDNS sends it. Certificates are checked against the built-in Mozilla root set, so you need no CA files, even in a minimal container.
DNS over TLS
[[upstream]]
name = "cloudflare-dot"
url = "tls://1.1.1.1" # DNS over TLS, port 853
tls_server_name = "cloudflare-dns.com" # name on the certificate
Many queries share one connection (pool_size, default 4), closed after idle_timeout_ms (default 30 s).
DNS over HTTPS
[[upstream]]
name = "quad9-doh"
url = "https://dns.quad9.net/dns-query" # DNS over HTTPS (HTTP/2)
bootstrap = ["9.9.9.9", "149.112.112.112"] # how to look up dns.quad9.net itself
[[upstream]]
name = "google-h3"
url = "h3://8.8.8.8/dns-query" # DoH over HTTP/3 (or https:// with http_version = "3")
tls_server_name = "dns.google"
Every query shares one HTTP/2 connection. doh_method = "get" sends GET requests instead of POST, for providers or HTTP caches that prefer it.
DNS over QUIC
[[upstream]]
name = "adguard-doq"
url = "quic://94.140.14.14" # DNS over QUIC, port 853
tls_server_name = "dns.adguard-dns.com"
One QUIC connection per upstream with a stream per query. It reconnects when the connection closes and never uses 0-RTT.
DNSCrypt and DNS stamps
Paste an sdns:// stamp (for example from the public list at dnscrypt.info) as the URL:
[[upstream]]
name = "adguard-dnscrypt"
url = "sdns://AQMAAAAAAAAAETk0LjE0MC4xNC4xNDo1NDQzINErR_JS3PLCu_iZEIbq95zkSV2LFsigxDIuUso_OQhzIjIuZG5zY3J5cHQuZGVmYXVsdC5uczEuYWRndWFyZC5jb20"
# relay = "sdns://gQ8yMDMuMC4xMTMuNzo0NDM" # Anonymized DNSCrypt: queries go through a relay
- A DNSCrypt stamp becomes a DNSCrypt v2 upstream. A DoH, DoT, or DoQ stamp becomes the matching
https://,tls://, orquic://upstream, with no bootstrap lookup needed. - With a
relay, the relay sees your address but can't read the queries, and the resolver sees only the relay's address. Pick a relay run by someone other than the resolver.
- Hostname upstreams are looked up through
bootstrapservers, or the system resolvers from/etc/resolv.confwhenbootstrapis empty. Never through TelltaleDNS itself. To skip the lookup, put the IP in the URL and settls_server_name. tls_insecure_skip_verify = trueturns certificate checks off and logs a warning. Don't use it on untrusted networks.
Client subnet (ECS)
Your devices' subnets are never sent. ecs = "203.0.113.0/24" sends a subnet you choose, for better CDN answers; ecs = "client" suits clients on public addresses. Details.
Through a proxy (Tor)
proxy = "socks5://127.0.0.1:9050" sends an upstream's traffic through SOCKS5 or HTTP, so the resolver never sees your address. Use tcp:// or tls:// with Tor. Details.
Pinning and client certificates
tls_ca, tls_client_cert, tls_client_key, and spki_pins for an office server with its own CA, mTLS, or a pinned key. Details.
Plugin upstreams
unix:// or exec://: anything that speaks DNS over a Unix socket can be an upstream. A dead plugin is a failing upstream like any other. Details.
Offer encrypted DNS to your devices
Phones, laptops, and browsers can reach TelltaleDNS over DNS over TLS (Android's "Private DNS"), DNS over HTTPS (browsers, iOS and macOS profiles), or DNS over QUIC, so nobody on the network path can read or change their lookups.
- Get a certificate for the name devices will use, such as
dns.example.comfrom Let's Encrypt (DNS-01 works for internal names): a PEM chain (leaf first) and its key. Both files are re-read within 10 seconds of changing, so renewals need no restart. - Add a listener for each protocol you want, as shown below. DoT uses TCP 853 and DoQ uses UDP 853, so both fit.
- Point a device at it:
dns.example.comas Android's Private DNS, orhttps://dns.example.com/dns-queryfor DoH.
DoT listener
[[listen]]
proto = "dot"
addr = "0.0.0.0:853"
tls = { cert = "/etc/telltale/tls.crt", key = "/etc/telltale/tls.key" }
DoH listener
[[listen]]
proto = "doh"
addr = "0.0.0.0:443"
path = "/dns-query" # default; /dns-query/<client-id> also works
tls = { cert = "/etc/telltale/tls.crt", key = "/etc/telltale/tls.key" }
[[listen]]
proto = "doh3" # DoH over HTTP/3: UDP 443 next to the TCP one
addr = "0.0.0.0:443"
tls = { cert = "/etc/telltale/tls.crt", key = "/etc/telltale/tls.key" }
DoH speaks HTTP/2 and HTTP/1.1. With a doh3 listener, HTTP/2 answers advertise HTTP/3 so browsers and apps that support it switch.
DoQ listener
[[listen]]
proto = "doq"
addr = "0.0.0.0:853" # UDP 853 (DoT uses TCP 853: both fit)
tls = { cert = "/etc/telltale/tls.crt", key = "/etc/telltale/tls.key" }
Kubernetes (Helm values)
encrypted:
dot:
enabled: true
doh:
enabled: true
http3: true
doq:
enabled: true
tls:
certManager:
enabled: true
issuerRef:
name: letsencrypt
dnsNames: [dns.example.com, "*.dns.example.com"]
Or use an existing Secret with encrypted.tls.secretName. The ports join the DNS LoadBalancer, so client addresses survive. See Helm values.
- Devices can identify themselves. With a wildcard certificate (
dns.example.comand*.dns.example.com), a device set tokids-tablet.dns.example.com, or to the URLhttps://dns.example.com/dns-query/kids-tablet, gets the client IDkids-tablet.[[client]] match = ["id:kids-tablet"]recognizes it wherever it is, even on mobile data. The path wins over the name. See Groups, devices, and VLANs. - Behind a load balancer:
proxy_protocol = trueon atcp,dot, ordohlistener reads the client's address from a PROXY protocol v2 header. Every connection must then start with one, so only turn it on when the balancer sends it. - Connection limits per listener:
max_connections(default 1,024) andmax_connections_per_address(default 32,0= no limit).
Metrics, timeouts, and HTTP details: Encrypted DNS for your devices in the docs.
Send a domain to another server
Some names only exist on another server: your work network over a VPN, your router's own names, another lab. Send everything under that domain there (conditional forwarding). Your local names still answer first.
Web UI
- Open Names on my network and choose Send a domain to another server.
- Enter the Domain (such as
corp.example) and the Server address (such as10.0.0.53). Separate several servers with spaces; they're tried in order. For an encrypted server, usetls://10.0.0.53. - Save.
Domains sent this way are left out of DNSSEC validation automatically (see below).
Config file
[[upstream]]
name = "home-router"
url = "udp://192.168.1.1"
[[upstream_group]]
name = "lan"
members = ["home-router"]
[[route]]
match_suffix = ["home.arpa", "168.192.in-addr.arpa"] # local names and reverse lookups
upstream_group = "lan"
The longest matching suffix wins. Routes can also match match_qtype = ["PTR"]. Add dnssec_nta = true to a route whose zone isn't signed.
API
PUT /api/v1/forwards/corp.example?dryRun=true
{"servers": ["10.0.0.53"]}
Servers are addresses (plain DNS) or udp://, tcp://, tls://, or https:// URLs, tried in order. Domains routed in the config files are read-only here (409). Agents use the MCP tool plan_forward_domain with the config:write:forwards scope.
Resolve without a forwarder
TelltaleDNS can resolve names itself, from the root servers down, like unbound next to a Pi-hole, so no public resolver sees your lookups:
[[upstream]]
name = "recursive"
url = "recursive://"
[[upstream_group]]
name = "default"
members = ["recursive"]
# Optional (these are the defaults):
# [upstream.recursive]
# qname_minimization = true # each server sees only as much of the name as it needs (RFC 9156)
# case_randomization = false # 0x20: random letter case, checked in answers (spoofing defense)
# ipv6 = false # also ask servers over IPv6 (needs an IPv6 route)
- Private by default: the root servers see only
com, thecomservers onlyexample.com, and onlyexample.com's own servers seewww.example.com. - Worth turning on:
case_randomization(off by default) asks with a random mix of upper and lower case and accepts only answers that echo it, which adds about one bit per letter to what an off-path spoofer must guess. - Fast after the first time: a first lookup in a new zone takes a few round trips (about 30 to 400 ms); after that, one.
- Mix it in: it's an upstream like any other. Use
strategy = "failover"with a public resolver as the fallback, or route only some domains through it.
Limits, root priming, and safety details: Recursive resolution in the docs.
Check DNSSEC signatures
TelltaleDNS can check DNSSEC signatures on answers itself instead of trusting the upstream. It's off by default.
[dnssec] mode | A signed but broken answer (forged, expired, tampered) |
|---|---|
off (default) | Not checked. |
permissive | Answered anyway, counted, and logged at INFO (DNSSEC: bogus answer served (permissive mode)). Good for trying validation out. |
validate | SERVFAIL with Extended DNS Error 6 (DNSSEC Bogus), or 7, 8, or 10 when the signatures show why. |
In both checking modes, valid answers carry AD for clients that ask, and unsigned answers are served as usual without it.
- Try it in
permissivemode first and read the log for a while. Only names that are really broken (likednssec-failed.org) should show up. - Exempt internal zones that aren't signed, then switch to
validate:[dnssec] mode = "validate" # off (default) | validate | permissive negative_trust_anchors = ["corp.example"] # internal zones that aren't signed
- A client that sets CD (checking disabled) gets the unvalidated answer.
- It works on cache misses only, and a validated answer is cached with its AD bit.
- The root's 2017 and 2024 keys are built in, so the root key rollover needs nothing from you.
NSEC proofs, unsigned-zone handling, and trust anchors: DNSSEC validation in the docs.
When an upstream fails
- Slow upstreams are hedged. If an upstream is slow, the next one is tried in parallel rather than after a timeout, and the first good answer wins.
- Failing upstreams are benched. One that fails 3 times in a row (or more than half the time) is benched for 10 seconds, doubling up to 5 minutes, then probed again. A SERVFAIL or REFUSED answer counts toward "more than half the time" but not "3 in a row", so one broken domain can't bench a healthy upstream.
- Stale answers keep you going. If upstreams don't answer within 1.8 s and an expired answer is still cached (up to a day old by default), it's served with TTL 30 and Extended DNS Error 3 ("Stale Answer") while the refresh continues.
- All failed: the client gets SERVFAIL with Extended DNS Error 22, and the SERVFAIL is cached for 5 seconds.
The Upstreams page's Answer quality card shows each upstream's DNSSEC verdicts and the error codes it attached. More in How a query is answered and How it works.
Troubleshooting
| What you see | What to do |
|---|---|
| Test it reports a TLS name that doesn't match | Set tls_server_name to the name on the server's certificate. |
| "no certificate in the server's chain matches the stamp's hashes" | The provider changed CAs before its stamp was updated. Take the newer stamp, or use the plain https:// or tls:// URL. |
| SERVFAIL with EDE 7 or 8 on many names | Signatures look expired or not yet valid: check the clock first. |
An internal domain fails in validate mode | Add it to negative_trust_anchors, or set dnssec_nta = true on its route. |
| A Tor or HTTP proxy upstream doesn't answer | They don't carry UDP: use tcp:// or tls://. |
| Encrypted connections refused behind a load balancer | If the balancer hides client addresses, every client looks like the balancer: raise max_connections_per_address or set it to 0, or use proxy_protocol if the balancer sends it. |
| A renewed certificate isn't used | A broken renewal keeps the old certificate and logs a warning. Check the log and the new files. |
| The log reports a loop | An upstream forwards back to TelltaleDNS. Point it somewhere else. |