Skip to content

DNS over TLS and HTTPS (DoT and DoH)

DTM can answer DNS over two encrypted transports alongside plain port 53: DNS-over-TLS (DoT, RFC 7858, its own TCP port 853) and DNS-over-HTTPS (DoH, RFC 8484, its own dedicated listener on port 443 serving GET/POST /dns-query and nothing else). Both ship disabled and turn on with one cluster-wide toggle. Answers over either transport go through exactly the same pipeline as UDP: load balancing, health filtering, and region-aware logic all apply.

  • Give the API a stable certificate first. Both transports serve the API listener’s TLS material: DoT wraps the DNS handler in it on port 853, and the DoH listener serves the same certificate and key on port 443 (there is no separate DoH certificate). By default that material is a self-signed certificate regenerated at every service start, which encrypted-DNS clients will refuse. Point listen.tls.cert_file and key_file at real certificate material on each node (the TLS certificates page has the procedure; the keys are in the configuration reference) before rolling either transport out to clients.
  • Open the encrypted-DNS ports deliberately. Allow TCP 853 (DoT) and TCP 443 (DoH) from your DNS clients to the DTM nodes in the NSG; both are in the deploy port table.
  • Treat DoH as a security decision, not a feature toggle.
The listener and port map: inside an NSG boundary, a DTM node exposes four listeners: 53 (plain DNS, UDP + TCP, the same pipeline as every transport), 853 (DoT over TCP, wrapping the DNS handler in the API TLS material), 443 (the dedicated DoH listener, serving GET and POST /dns-query and nothing else, only when enabled), and 8443 (API and management, admin-allowlist only, no DNS served). A bracket joins 853, 443, and 8443 to one shared listen.tls cert and key. Three source groups reach in: DNS clients to 53 and 853, DoH resolver clients (only if DoH is enabled) to 443 over a dashed arrow annotated unauthenticated by design, scope this rule, and the admin allowlist to 8443. An amber note carries the prerequisite: give the API a stable certificate first, because the default self-signed certificate regenerates at every start and encrypted-DNS clients will refuse it. The listener and port map: inside an NSG boundary, a DTM node exposes four listeners: 53 (plain DNS, UDP + TCP, the same pipeline as every transport), 853 (DoT over TCP, wrapping the DNS handler in the API TLS material), 443 (the dedicated DoH listener, serving GET and POST /dns-query and nothing else, only when enabled), and 8443 (API and management, admin-allowlist only, no DNS served). A bracket joins 853, 443, and 8443 to one shared listen.tls cert and key. Three source groups reach in: DNS clients to 53 and 853, DoH resolver clients (only if DoH is enabled) to 443 over a dashed arrow annotated unauthenticated by design, scope this rule, and the admin allowlist to 8443. An amber note carries the prerequisite: give the API a stable certificate first, because the default self-signed certificate regenerates at every start and encrypted-DNS clients will refuse it.

The runtime toggle is the primary surface. In the web UI, Settings then Transport Protocols shows each transport’s state with a toggle (admin role); teams that manage cluster config as code use the dtm_transport_config Terraform resource instead. Over the API, with $DTM_TOKEN holding an admin bearer token (how to capture one):

Terminal window
curl -sk -X PUT https://dtm.internal:8443/api/v1/transport-config \
-H "Authorization: Bearer $DTM_TOKEN" \
-H "X-DTM-CSRF: 1" \
-H "Content-Type: application/json" \
-d '{"dot_enabled": true, "doh_enabled": true}'

Either way, the change applies live and replicates cluster-wide: every node brings its listeners up (or down) to match, with no restarts. Details worth knowing:

  • The request body is partial: send only the fields you are changing (dot_enabled, dot_listen_addr, doh_enabled, doh_listen_addr). A misspelled field is a 400, never a silent no-op, because this setting fans out to every node.
  • dot_listen_addr defaults to ":853" and doh_listen_addr to ":443"; both must be host:port values, and an empty host means all interfaces. Changing either live moves that listener, but the two orders differ: DoT stops the old address, then starts the new one; DoH binds the new address first and releases the old one only once the bind succeeds, so an unbindable doh_listen_addr leaves DoH serving where it was.
  • Disabling DoH stops its listener, so port 443 closes rather than the route answering 404.
  • GET /api/v1/transport-config returns the effective state, including who changed it last and when, and the change itself is audit-logged.
  • If DoT is enabled but a node cannot load its TLS material, that node logs the error and leaves DoT down rather than serving a broken listener; check the node’s logs if 853 does not answer.

The configuration file sets what the transports start as on a fresh cluster:

listen:
dot:
enabled: true
listen_addr: ':853'
doh:
enabled: true
listen_addr: ':443'

On the Terraform path the node module exposes the same as dot_enabled and doh_enabled variables. Two things follow from “boot default”:

  • A config-file edit takes effect at the next service restart, like any other boot-time setting.
  • Once anyone has made a runtime change (UI or API), the persisted runtime setting wins over the YAML at startup. The file is the initial state, not an override.

With kdig (from knot-dnsutils) against any node:

Terminal window
# DoT
kdig @<node-ip> app.example.internal +tls
# DoH
kdig @<node-ip> app.example.internal +https=/dns-query

Both should return the same answers as dig @<node-ip> on port 53, including health-filtered and region-aware results. For DoH without kdig: POST /dns-query takes a wire-format DNS message with Content-Type: application/dns-message and answers in kind.