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.

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

  1. Open the Upstreams page. It holds both servers and upstream groups.
  2. Add or edit an entry. Simple view shows the common fields; switch to Advanced for timeouts and TLS names.
  3. 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).
  4. 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://, or quic:// 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 bootstrap servers, or the system resolvers from /etc/resolv.conf when bootstrap is empty. Never through TelltaleDNS itself. To skip the lookup, put the IP in the URL and set tls_server_name.
  • tls_insecure_skip_verify = true turns 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.

  1. Get a certificate for the name devices will use, such as dns.example.com from 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.
  2. Add a listener for each protocol you want, as shown below. DoT uses TCP 853 and DoQ uses UDP 853, so both fit.
  3. Point a device at it: dns.example.com as Android's Private DNS, or https://dns.example.com/dns-query for 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.com and *.dns.example.com), a device set to kids-tablet.dns.example.com, or to the URL https://dns.example.com/dns-query/kids-tablet, gets the client ID kids-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 = true on a tcp, dot, or doh listener 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) and max_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

  1. Open Names on my network and choose Send a domain to another server.
  2. Enter the Domain (such as corp.example) and the Server address (such as 10.0.0.53). Separate several servers with spaces; they're tried in order. For an encrypted server, use tls://10.0.0.53.
  3. 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, the com servers only example.com, and only example.com's own servers see www.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] modeA signed but broken answer (forged, expired, tampered)
off (default)Not checked.
permissiveAnswered anyway, counted, and logged at INFO (DNSSEC: bogus answer served (permissive mode)). Good for trying validation out.
validateSERVFAIL 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.

  1. Try it in permissive mode first and read the log for a while. Only names that are really broken (like dnssec-failed.org) should show up.
  2. 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 seeWhat to do
Test it reports a TLS name that doesn't matchSet 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 namesSignatures look expired or not yet valid: check the clock first.
An internal domain fails in validate modeAdd it to negative_trust_anchors, or set dnssec_nta = true on its route.
A Tor or HTTP proxy upstream doesn't answerThey don't carry UDP: use tcp:// or tls://.
Encrypted connections refused behind a load balancerIf 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 usedA broken renewal keeps the old certificate and logs a warning. Check the log and the new files.
The log reports a loopAn upstream forwards back to TelltaleDNS. Point it somewhere else.