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.
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.
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.
Everything HTTP/3 lives under one TOML table. It is disabled
until you set enabled = true.
[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.
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.
apt install certbot python3-certbot-dns-cloudflare 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 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.
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.
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.
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.
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.
Two checks: a curl built with HTTP/3 support, and the browser's own devtools.
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.
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.
HTTP/3 live at the edge. Round out the operational guides.