HTTP/3 and QUIC on NGINX: Real-World Setup, Tuning, and Gotchas (2026)

About 32% of the top-1000 websites already negotiate HTTP/3 with the browsers that ask for it, according to W3Techs’ 2026 survey. Cloudflare, Google, Meta, and most CDNs have been quietly doing it for years. And yet, when you try to turn HTTP/3 on in your own NGINX, you discover that the upstream config example in the wiki is a polite lie. It compiles. It binds. It even logs “QUIC”. Then a tcpdump shows you exactly zero UDP packets leaving the box, because your firewall doesn’t know what 443/udp is, your kernel’s UDP receive buffer is the size of a postcard, and your reverse proxy in front of it is a TCP-only load balancer that drops the alt-svc header on the floor.

This is the real-world HTTP/3 setup. The one that works in production. We’ll cover what QUIC actually is and why it matters, what HTTP/3 support looks like in mainline NGINX versus Angie versus our deb.myguard.nl builds, a complete listen-block you can paste, the handful of sysctl knobs that turn it from “kinda works” into “fast”, and the gotchas that have eaten more weekends than I’d like to admit.

What HTTP/3 and QUIC actually are

HTTP/3 is HTTP/2’s semantics riding on top of QUIC instead of TCP. That’s the whole story, told fast. Same requests, same responses, same headers, same streams. The transport underneath is different, and it’s the transport that buys you everything interesting.

QUIC (Quick UDP Internet Connections, because the IETF likes a recursive acronym) is a transport protocol layered on UDP that does what TCP+TLS used to do, but in one handshake instead of three. It was born at Google around 2012 as gQUIC, got dragged into the IETF, mutated for half a decade, and finally shipped as RFC 9000 in 2021. HTTP/3 is RFC 9114. Both stabilised years ago. The reason your stack might not speak it yet is purely sociological: the kernel folks, the load-balancer folks, the firewall folks, and the WAF folks all had to update their stuff, and that takes time.

The headline wins:

  • One handshake instead of two. A normal HTTPS connection over TCP costs you one round-trip to set up the TCP socket, then another for TLS. QUIC folds TLS 1.3 into the transport itself. First connection: 1-RTT. Resumed connection: 0-RTT (you can send the GET in the very first packet). Half the time-to-first-byte on a cold connection, basically for free.
  • Head-of-line blocking is gone. HTTP/2 multiplexed streams over a single TCP connection, which sounded great until one packet got dropped and every stream stalled waiting for the retransmit. QUIC moves stream multiplexing below the loss-recovery layer, so a lost packet only stalls the stream that packet belonged to. The other streams keep flowing. This is the single biggest deal for anyone serving lots of small assets.
  • Connection migration. A QUIC connection is identified by a connection ID, not by the four-tuple of IPs and ports. Your phone leaves wifi, switches to 5G, gets a new IP — the connection keeps going. No reconnect. No re-handshake. Streaming video doesn’t even pause.
  • Encryption is mandatory and it’s all of it. QUIC encrypts the transport headers, not just the payload. Middleboxes can’t peek at sequence numbers, can’t rewrite flags, can’t fingerprint the way they used to. This is also why some “DPI-aware” firewalls hate it.

The trade-off: it’s UDP. Some networks throttle UDP, some firewall it entirely, and CPU usage per byte is higher than TCP because everything happens in userspace. (There’s kernel offload work happening — GSO, GRO, the io_uring stuff — but we’ll get to that.)

HTTP/3 support: mainline NGINX vs Angie vs our packages

This is where the documentation gets confusing because three timelines are running in parallel.

Mainline NGINX shipped experimental QUIC and HTTP/3 in 1.25.0 (May 2023), and marked it production-ready in 1.25.3 (October 2023). As of 2026, every NGINX 1.27.x and 1.29.x mainline release has it built in. You don’t need a separate nginx-quic branch any more — that fork was merged into mainline and archived. Stable branch (1.26.x, 1.28.x) also has it; F5 backported once they decided the dust had settled.

What you do still need: an OpenSSL with QUIC API support. Mainline NGINX uses OpenSSL’s compat shim, which works but doesn’t expose every TLS feature. For 0-RTT and full ALPN control you want one of quictls, BoringSSL, AWS-LC, or OpenSSL 3.5+ (which finally landed proper QUIC server-side API). The ./configure dance has been written about a thousand times; the short version is --with-http_v3_module --with-cc-opt=-I/path/to/quictls/include --with-ld-opt=-L/path/to/quictls/lib.

Angie (the fork from former NGINX-core devs after the F5 acquisition) has had HTTP/3 since the very first release and treats it as a first-class feature. The directives are mostly compatible with mainline NGINX’s, plus a couple of Angie-specific ones (quic_active_connection_id_limit, finer-grained quic_log). Angie also has built-in support for live monitoring of QUIC connections via its /status endpoint, which is genuinely nice when you’re debugging.

Our deb.myguard.nl NGINX packages ship with HTTP/3 enabled by default, linked against quictls, and bundled with the full compression stack and the rest of our dynamic module set. Same goes for our Angie packages. You don’t need to compile anything; apt install nginx from our repo and the binary already speaks h3. The packages also bundle the BoringSSL link variant if you’d rather have that (it tends to be slightly faster on the QUIC hot path, at the cost of dropping a few legacy TLS features almost nobody uses).

A practical heuristic: if you’re already running NGINX 1.25.3+ from any reputable source, you have HTTP/3. The question isn’t “do I have it” but “is the config right and is the network plumbing in shape”.

The minimum viable HTTP/3 listen block

Here’s a config that actually works. Drop it in your server block alongside your existing HTTPS listen:

server {
    # Existing HTTP/1.1 + HTTP/2 over TCP
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;

    # HTTP/3 over QUIC (UDP)
    listen 443 quic reuseport;
    listen [::]:443 quic reuseport;

    # Same TLS config as your existing HTTPS
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols       TLSv1.3;
    ssl_early_data      on;            # enables 0-RTT for resumed sessions

    # Tell browsers HTTP/3 is available on this host
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    # Optional: hint that early data is being served
    add_header X-Early-Data $ssl_early_data always;

    server_name example.com;
    root /var/www/example.com;
}

A few things worth pointing out, because they trip people up every time:

reuseport is not optional, it’s the whole point. Without it, every QUIC packet gets dispatched through a single listening socket and then handed to a worker — which murders multi-core throughput because that one socket becomes a contention hot spot. With reuseport, each worker gets its own UDP socket bound to the same port via SO_REUSEPORT, and the kernel hashes incoming packets across them. On a 16-core box, this is the difference between 4 Gbps and 40 Gbps.

ssl_early_data on enables 0-RTT. Resumed connections can send the HTTP request in the very first QUIC packet. This is brilliant for repeat visitors but it has a footgun: 0-RTT data is replayable, which means it must be safe to replay. GET requests to idempotent endpoints, fine. POST to /transfer-money, not fine. Most apps don’t care because browsers only send GETs in 0-RTT, but if you have weird shaped traffic, check.

The Alt-Svc header is how a browser learns your server speaks HTTP/3. The browser hits you over TCP+HTTP/2 first, sees the header, remembers it for ma seconds (max-age — 86400 is one day), and on the next request it tries QUIC first. If your alt-svc header doesn’t reach the browser, no client will ever speak HTTP/3 to you, no matter how perfectly your QUIC config works. This is the most common “why isn’t this working” cause, and we’ll come back to it.

Tuning that actually matters

HTTP/3 QUIC NGINX tuning sysctl UDP buffers and reuseport
The sysctl knobs and listen-block flags that turn HTTP/3 from “kinda works” into “fast”.

The default UDP receive buffer on a Linux box is somewhere between 200 KB and 4 MB depending on distribution. For an HTTP/3 server doing real traffic, that’s tiny. Bursts of incoming packets overflow the socket buffer, the kernel drops them, QUIC sees loss, slams the congestion window shut, and your throughput collapses to gigabit-modem speeds. Fix it:

# /etc/sysctl.d/99-quic.conf
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.core.rmem_default = 2097152
net.core.wmem_default = 2097152
net.core.netdev_max_backlog = 5000
# Then: sysctl --system

You also need NGINX to actually ask the kernel for those bigger buffers. The listen directive has a rcvbuf / sndbuf param:

listen 443 quic reuseport rcvbuf=2m sndbuf=2m;

Without those, NGINX uses the default and you’ve sysctl’d for nothing.

Other QUIC-specific NGINX directives worth knowing about:

  • quic_retry on; — turns on the QUIC retry mechanism, which makes the client prove its source address before you allocate state. This is your DDoS defence; without it, a single-packet UDP source-spoofing attack can make your server allocate state for millions of fake “connections”. Cost: one extra round-trip on the first connection. Worth it.
  • quic_gso on; — Generic Segmentation Offload. The kernel sends multiple UDP segments as a single sendmsg() call and lets the NIC slice them up. On any reasonable NIC from the last decade this is a 2-3x CPU reduction on the send path. Default is off in most builds because old NICs don’t handle it, but if your hardware is from 2015 or later, turn it on.
  • quic_active_connection_id_limit 4; — how many connection IDs the client can have active at once. Higher numbers help with connection migration and NAT rebinding. Default is 2; bumping to 4 or 8 is common.
  • http3_stream_buffer_size 64k; — per-stream buffer. Default is small. Bigger means more memory per connection but better throughput on high-bandwidth streams.

If you want to see what’s actually happening, quic_log writes per-packet info to a file. It’s verbose enough that you only want it on for debugging, but for “why is this connection slow” it’s the only way to get ground truth without strace-ing the worker.

The gotchas that will bite you

This is the section I wish I’d had when I started. None of these are bugs in NGINX; they’re all environment issues that NGINX has no way to warn you about.

1. Your firewall is dropping 443/udp

Run ufw status or iptables -L INPUT -n. If you see 443/tcp allowed and nothing about UDP, the kernel is silently dropping every incoming QUIC packet. ufw allow 443/udp, or the iptables equivalent, and re-test. On AWS / GCP / Azure, the security-group rule also defaults to TCP-only — UDP needs an explicit rule. If you have a Cloudflare or Fastly proxy in front, they handle this for you; for everything else, check.

2. The alt-svc header isn’t reaching the client

Every reverse proxy, every CDN, every WAF can strip headers it doesn’t recognise. If you’re behind something old, run curl -sI https://example.com/ | grep -i alt-svc from outside and see what comes back. No header, no HTTP/3. The fix is usually a single line in the proxy config to pass it through, but you have to know to look.

Related: if you set the header without always, NGINX won’t add it to 4xx/5xx responses, and some health-check tools only see 200s from cached error pages and conclude HTTP/3 isn’t enabled.

3. MTU and PMTU discovery

QUIC packets are bigger than the legacy 1280-byte IPv6 minimum because the protocol was designed for modern networks. When a packet exceeds the path MTU, it gets fragmented, and UDP fragments are often dropped by firewalls (because attackers love fragmented UDP). Symptoms: handshake works, then large responses hang or stall. NGINX handles this with QUIC’s built-in PMTU discovery, but if you’ve set net.ipv4.ip_no_pmtu_disc=1 somewhere for “TCP reasons”, that breaks QUIC’s discovery too. Leave PMTU discovery on.

4. Your load balancer is TCP-only

This one’s the killer. If you have an HAProxy, AWS ALB, GCP HTTPS LB, or Nginx Plus in front of your NGINX, check whether it does UDP at all. The classic ALB doesn’t. The new Network Load Balancer does. HAProxy 2.6+ has experimental QUIC support but won’t proxy QUIC to a backend — it terminates it. If your LB doesn’t speak QUIC, you have two choices: terminate QUIC at the LB and proxy plain HTTP/1.1 or H2 to the backend, or bypass the LB entirely for UDP traffic (some people run a separate hostname for QUIC, which is ugly but works).

5. ModSecurity and HTTP/3

If you’ve followed our guide on installing ModSecurity and the OWASP CRS, almost everything works the same over QUIC because ModSecurity sees the request after NGINX has parsed it — the transport is invisible. Two caveats: (a) some older CRS rules look at $server_protocol and assume HTTP/1.1 or HTTP/2 string-matches; HTTP/3 connections show HTTP/3.0, and those rules silently miss-fire. Update to CRS 4.x if you’re still on 3.x. (b) Rate-limiting by source IP gets harder when the client migrates connections across IPs (see “connection migration” up top) — the QUIC connection ID is the stable identifier, not the IP, and most WAF rate limiters don’t know about it yet.

While we’re on security: HTTP/3 doesn’t make you immune to BREACH-style compression side-channel attacks. Same compression, same secrets, same problem. Disable HTTP-level compression on responses that mix secrets with attacker-influenced input, the same way you would on HTTP/2.

6. Older Chromium versions and the alt-svc cache

Chrome caches alt-svc records aggressively. If you initially served a broken alt-svc header (wrong port, typo in h3=), Chrome remembers it and keeps trying for the ma duration. You change the server, run curl, see the new header, but your test browser still uses the cached broken one. Visit chrome://net-internals/#alt-svc and clear, or pick a different browser to test fresh. Spent two hours on this once. Never again.

Verifying it actually works

Four tools, in increasing order of paranoia:

curl –http3 (requires a curl built against a QUIC-capable TLS library — Ubuntu 24.04’s curl can do it if you install libcurl4-openssl-dev from a recent source, or just use the curl in our packages):

curl --http3 -sI https://example.com/ | head -1
# Expected: HTTP/3 200

If curl falls back to HTTP/2, it’ll show HTTP/2 200 instead. Check stderr with -v to see why.

Chrome DevTools: open the Network tab, right-click any column header, enable the “Protocol” column. Reload the page. You should see h3 next to your requests. First page-load after a clean cache will be h2 (browser learns alt-svc), and subsequent loads switch to h3. If you never see h3, the alt-svc header isn’t reaching the browser — back to gotcha #2.

h3check.net and http3check.net: third-party probes that hit your domain and tell you what they found. Useful sanity check from outside your own network.

Wireshark / tcpdump on the server: tcpdump -i any -nn 'udp port 443'. If you see no UDP traffic at all when a real client visits, the firewall or NAT is eating your packets before they hit NGINX. If you see UDP packets but NGINX logs no QUIC connections, the listen block isn’t binding the UDP socket — check ss -ulpn | grep :443, the QUIC listener should appear with the NGINX worker PIDs.

When HTTP/3 isn’t worth it

HTTP/3 is wonderful for the open web, where users are on lossy mobile networks loading lots of small assets from a long way away. It is less wonderful for:

  • Backend-to-backend traffic on a fast LAN. No packet loss, no head-of-line blocking to fix. The userspace QUIC stack just adds CPU overhead versus plain old TCP+H2.
  • Long-lived single-stream connections. Server-sent events, websockets-equivalent persistent streams: QUIC gives you very little here because there’s no multiplexing to benefit from.
  • Networks where UDP gets shaped or blocked. Some corporate networks, some hotels, some airline wifi. The browser will silently fall back to TCP, so you’re not broken, but you’ve paid the implementation cost for users who can’t use it.

The good news is that turning it on is additive. Your TCP+H2 listener keeps working for clients that can’t or won’t speak QUIC, and the browsers that can speak it get the benefits transparently. There’s almost no downside to enabling it correctly — the downside is enabling it incorrectly and shipping a half-broken alt-svc header that makes browsers waste time trying QUIC against a dead UDP socket.

FAQ

Do I need a special build of NGINX for HTTP/3?

Not since NGINX 1.25.3 (October 2023). Mainline and stable both have HTTP/3 built in. You need an OpenSSL/quictls/BoringSSL build with QUIC API support, but that’s a build-time concern, not a runtime one. Our deb.myguard.nl packages already include this.

Does HTTP/3 replace HTTP/2?

No. They coexist. Your server should listen on both. Clients that support HTTP/3 will switch to it after seeing your alt-svc header; clients that don’t (older browsers, most CLI tools, anything behind a UDP-blocking firewall) will keep using HTTP/2 over TCP.

Is HTTP/3 faster than HTTP/2 for everyone?

On lossy networks (mobile, distant CDN edge, congested wifi), measurably yes — sometimes 20-30% faster page-load. On a local LAN with no packet loss, the difference is negligible or even slightly worse because of the userspace QUIC processing overhead. The win comes from solving real-world network problems, not from being faster in lab conditions.

Why is my browser not using HTTP/3 even though I configured it?

In ~90% of cases it’s the alt-svc header not reaching the browser. Run curl -sI from outside and check. The other 10% is a UDP firewall block, a CDN/proxy in front that doesn’t pass UDP, or Chrome caching a stale alt-svc record (clear via chrome://net-internals/#alt-svc).

Does HTTP/3 work with Let’s Encrypt and standard TLS certificates?

Yes, it uses the same X.509 certificates as HTTPS. QUIC mandates TLS 1.3, so make sure your cert is signed with a TLS-1.3-compatible chain (which Let’s Encrypt has been doing for years). No special cert needed.

What’s the difference between HTTP/3 in NGINX and in Angie?

Mainline NGINX added HTTP/3 in 1.25.x and treats it as a normal feature. Angie (the post-F5 fork) had HTTP/3 from day one and exposes more fine-grained directives (e.g. quic_log, additional connection-ID controls) plus built-in live monitoring of QUIC connections via its status endpoint. Functionally equivalent for most workloads.

Will HTTP/3 break my reverse proxy setup?

It can. Many load balancers and reverse proxies only speak TCP and silently drop UDP traffic. Check whether your LB does UDP (AWS NLB yes, classic ALB no; HAProxy 2.6+ yes but terminates QUIC rather than proxying it). If it doesn’t, terminate QUIC at the edge and proxy plain HTTP/2 to the backend.

Related reading