Back to all posts

Adding DNS-over-QUIC to Blocky for Faster Encrypted DNS

My homelab runs Blocky as the DNS resolver for all VLANs — ad-blocking, custom DNS entries, conditional forwarding to Knot for DHCP hostnames. Upstream queries go out encrypted via DNS-over-TLS (DoT) to Cloudflare and Google. It works, but every cache miss costs about 20ms due to the TCP+TLS handshake overhead, and with a 72% cache hit rate, that penalty hits roughly 1 in 4 queries.

The DoT Problem

DoT runs DNS over TCP with TLS on port 853. Every new connection needs:

  • TCP handshake: 1 RTT (~4ms to Cloudflare)
  • TLS handshake: 2 RTTs (~8ms)
  • DNS query + response: 1 RTT (~4ms)
  • Total: ~16-20ms per cold query

Blocky does connection keepalive, but with strategy = "random" across four upstream servers, connections get spread thin and often go cold. The result on my setup:

Query typeLatency
Blocky cached0-1ms
Blocky cold (DoT upstream)14-24ms
Plain UDP to same servers3-6ms

That gap between 4ms (plain UDP) and 20ms (DoT) is entirely TLS overhead. I wanted encrypted DNS without the latency tax.

DNS-over-QUIC

DNS-over-QUIC (DoQ, RFC 9250) runs DNS over QUIC instead of TCP+TLS. QUIC is a UDP-based transport with built-in encryption, and it has two properties that matter here:

  1. 1-RTT handshake on first connection (vs 2-3 for TCP+TLS)
  2. 0-RTT session resumption on reconnection — subsequent queries skip the handshake entirely

With a persistent connection (which is how a resolver should work anyway), DoQ queries have the same latency as plain UDP. The encryption is free.

Blocky doesn’t support DoQ. The feature request has been open since 2021. So I implemented it.

Implementation

Blocky is written in Go and has a clean upstream client interface:

type upstreamClient interface {
    fmtURL(ip net.IP, port uint16, path string) string
    callExternal(
        ctx context.Context, msg *dns.Msg, upstreamURL string,
        protocol model.RequestProtocol,
    ) (response *dns.Msg, rtt time.Duration, err error)
}

DoT and DoH each implement this interface. Adding DoQ meant writing a third implementation using quic-go.

The changes touched four areas:

Protocol enum (config/config.go) — added quic to the NetProtocol enum alongside tcp+udp, tcp-tls, and https. Default port 853, same as DoT per RFC 9250.

Upstream parsing (config/upstream.go) — added quic: prefix support so you can write quic:dns.quad9.net:853, and wired up DNS stamp parsing for DoQ stamps (sdns:// format).

QUIC client (resolver/upstream_resolver_quic.go) — the actual DoQ implementation. The wire format per RFC 9250 is straightforward: open a QUIC stream, write a 2-byte length prefix followed by the DNS message, half-close the send side, read the response. Each query gets its own stream on a shared QUIC connection.

The interesting parts are connection management:

// Uses DialAddrEarly for 0-RTT support on reconnection
conn, err := quic.DialAddrEarly(ctx, addr, tlsCfg, &quic.Config{
    Allow0RTT: true,
})

The client maintains a persistent QUIC connection and reuses it across queries. If the connection dies (idle timeout, server restart), it detects the closed context and redials. DialAddrEarly enables 0-RTT session resumption, so even reconnections are fast.

ALPN negotiation uses doq as required by the RFC:

tlsCfg := r.tlsConfig.Clone()
tlsCfg.NextProtos = []string{"doq"}

Total diff: ~480 lines added across 10 files, including a mock DoQ server and tests. The whole thing took an afternoon.

Deploying on NixOS

My infra repo uses Nix flakes. To use the fork, I added it as a flake input and overlaid the blocky package:

# flake.nix
inputs.blocky-src = {
  url = "github:elsbrock/blocky/feat/doq-upstream";
  flake = false;
};
# overlays/default.nix
blocky = prev.buildGoModule {
  pname = "blocky";
  version = "0.27.0-doq";
  src = inputs.blocky-src;
  vendorHash = "sha256-/c92AbicmwLZYnsz5ISa4qa0NFpX76PdpJIXYN/Ij2Y=";
  doCheck = false;
  ldflags = [
    "-s" "-w"
    "-X github.com/0xERR0R/blocky/util.Version=0.27.0-doq"
  ];
};

Then switched the Blocky config from DoT to DoQ:

upstreams = {
  groups = {
    default = [
      "quic:dns.quad9.net:853"
      "quic:unfiltered.adguard-dns.com:853"
      "quic:dns.nextdns.io:853"
    ];
  };
  timeout = "6s";
  strategy = "parallel_best";
};

A few notes on the choices here: Cloudflare and Google don’t support DoQ yet, so the provider pool is smaller. I’m using unfiltered resolvers because Blocky handles ad/tracker blocking itself — no point in double-filtering. The parallel_best strategy queries all three in parallel and takes the fastest response, which keeps all QUIC connections warm and avoids cold handshake penalties.

I also bumped the cache TTLs — maxTime from 2 hours to 12 hours. For a home LAN, stale DNS is rarely an issue, and it drastically reduces upstream queries.

Results

After deploying, cold query latency dropped from 14-24ms to 3-4ms:

Query typeBefore (DoT)After (DoQ)
Cached0-1ms0-1ms
Cold (upstream)14-24ms3-4ms

That 3-4ms is essentially the raw network RTT to the upstream server — the QUIC encryption adds no measurable overhead once the connection is established. Every query is still fully encrypted on the wire.

I’ve submitted this as PR #2013 upstream. Until it’s merged, the fork is at github.com/elsbrock/blocky. Configuration is identical to DoT, just swap tcp-tls: for quic:.

Comments