DOCS · ADMIN · HTTP/3

Faster edges.

HTTP/3 is off by default. Flip it on with the [http3] block in /etc/qnt/server.toml, give it a wildcard cert, open UDP/443 through both firewall layers, and advertise it with an Alt-Svc header so browsers upgrade on their own.

1 What HTTP/3 buys you

HTTP/3 runs over QUIC on UDP instead of TCP. That removes head-of-line blocking between streams and lets connections survive a network change — useful for the kind of mobile and flaky-link clients that hit public tunnels.

Terminated in-process, not at nginx

HTTP/3 termination happens inside qnt-server itself, via quinn + h3 + h3-quinn. It is not terminated at nginx. This is a deliberate architecture decision (ADR-010: HTTP/3 termination in qnt-server, not LXC nginx) — the QUIC endpoint and the HTTP/1.1+2 TCP path are separate listeners.

HTTP/3 lives only at the public edge. The agent transport (TLS + TCP + yamux) is a separate channel and is unaffected by anything in this guide.

2 The [http3] config block

Everything HTTP/3 lives under one TOML table. It is disabled until you set enabled = true.

Configure [http3]

[http3]
enabled = true
bind = "0.0.0.0:443"
cert_path = ""
key_path = ""
  • enabled — bool, defaults to false. The QUIC endpoint stays down until this is true.
  • bind — UDP bind for the QUIC endpoint. Defaults to 0.0.0.0:443. Override to e.g. 0.0.0.0:8443 for local dev that can't bind privileged ports.
  • cert_path — PEM cert chain. Empty reuses [server.tls].cert_path.
  • key_path — PEM private key. Empty reuses the [server.tls] key.

Leaving cert_path and key_path empty is fine if your existing [server.tls] cert already covers the tunnel hostnames. The next section sets up the wildcard cert that does.

3 A wildcard cert via certbot DNS-01

Tunnels get subdomains, so you need a wildcard *.example.com (plus the apex). Wildcards require the DNS-01 challenge — here, the certbot Cloudflare DNS plugin.

Install certbot + the Cloudflare plugin

apt install certbot python3-certbot-dns-cloudflare

Drop in a scoped API token

Write a Cloudflare API token (DNS edit on the zone) to a credentials file and lock it down:

echo "dns_cloudflare_api_token = <token>" > /etc/qnt/cloudflare-dns.ini
chmod 600 /etc/qnt/cloudflare-dns.ini

Issue the cert

certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /etc/qnt/cloudflare-dns.ini \
  -d '*.example.com' -d 'example.com' \
  --non-interactive --agree-tos -m you@example.com \
  --deploy-hook "systemctl reload qnt-server"

Then point [http3] at the live certbot paths:

[http3]
cert_path = "/etc/letsencrypt/live/example.com/fullchain.pem"
key_path = "/etc/letsencrypt/live/example.com/privkey.pem"

The --deploy-hook reloads qnt-server on every renewal, so renewed certs are picked up without manual intervention.

4 The UDP/443 firewall path

QUIC is UDP, so UDP/443 has to reach the server. In a two-tier edge — an LXC or router in front of the host — you need both a DNAT rule on the front router and a host firewall allow. Miss either and HTTP/3 silently fails.

Both layers, in order

  • Front router — a UDP DNAT rule (iptables/nft) forwarding UDP/443 to the server.
  • Host firewall — on the production setup this was a VM ufw allow: ufw allow proto udp from <private-cidr> to any port 443.

Gotcha (verified — this bit us in testing): tcpdump can see the UDP packets arriving while the app still doesn't receive them. That's the host ufw layer dropping them after the front router already forwarded them — not a routing problem. Always check both layers; a tcpdump hit at the host proves the router did its job and points the finger straight at the host firewall.

5 Advertise it with Alt-Svc

Browsers reach you over HTTP/1.1 or HTTP/2 on TCP first. They only switch to HTTP/3 once you tell them it exists, via an Alt-Svc response header.

Add the header

Alt-Svc: h3=":443"; ma=86400

If nginx fronts HTTP/1.1+2 on TCP/443, add the Alt-Svc header there so it rides every response the browser sees on the TCP path.

ma=86400 tells the browser to remember the h3 endpoint for a day, so the upgrade sticks across requests.

6 Verify it's live

Two checks: a curl built with HTTP/3 support, and the browser's own devtools.

curl --http3

curl --http3 https://yourtunnel.example.com/ -I

This needs a curl compiled with HTTP/3. In the browser, open devtools and confirm the request protocol shows h3.

Reload after cert or config changes

systemctl reload qnt-server    # graceful
systemctl restart qnt-server   # full restart

A graceful reload picks up renewed certs and config edits without dropping live connections. Reach for a full restart only when reload isn't enough.

Next

HTTP/3 live at the edge. Round out the operational guides.