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 type | Latency |
|---|---|
| Blocky cached | 0-1ms |
| Blocky cold (DoT upstream) | 14-24ms |
| Plain UDP to same servers | 3-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-RTT handshake on first connection (vs 2-3 for TCP+TLS)
- 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 type | Before (DoT) | After (DoQ) |
|---|---|---|
| Cached | 0-1ms | 0-1ms |
| Cold (upstream) | 14-24ms | 3-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:.