Back to Blog
28 min Copy as Markdown
Part 3 / 5 · Hardening Nginx, edge inward

Mastering QUIC and HTTP/3 with Nginx: The Complete Guide

QUIC makes every stream independent at the transport layer, so one lost packet stalls only its own stream instead of all of them as in HTTP/2 over TCP. Nginx needs 1.25.0+ and UDP/443 open, or it falls back to HTTP/2 without telling you.

Cover image for Mastering QUIC and HTTP/3 with Nginx: The Complete Guide

The web has relied on TCP for over four decades. But modern applications—with their demands for real-time interactivity, mobile connectivity, and instant page loads—have exposed TCP’s fundamental limitations. Enter QUIC and HTTP/3: a complete reimagining of web transport that abandons TCP in favor of UDP to deliver a faster, more secure, and resilient internet.

In this comprehensive guide, we’ll explore the history and architecture of QUIC, understand why it solves problems that TCP cannot, and walk through a complete production-ready Nginx configuration.

TL;DR — QUIC and HTTP/3 on Nginx

6 key points
  • QUIC runs over UDP, not TCP: HTTP/3 maps HTTP semantics onto QUIC, which is built on UDP and mandates TLS 1.3.
  • Solves HOL blocking: QUIC streams are independent at the transport layer, so a lost packet only stalls its own stream — not all multiplexed streams as in HTTP/2 over TCP.
  • Faster handshakes and connection migration: combined transport+crypto handshake (1-RTT, or 0-RTT on resume) plus Connection IDs that survive network changes (Wi-Fi → mobile).
  • Nginx needs 1.25.0+ with --with-http_v3_module and a QUIC-capable SSL library (OpenSSL 3.5.1+, BoringSSL, QuicTLS, or LibreSSL 3.6+).
  • Core config: add listen 443 quic reuseport;, enable http3 on, TLS 1.3, and the Alt-Svc header; reuseport must appear on exactly one listener per address:port.
  • Open UDP/443 on the firewall (server and cloud provider) or QUIC fails silently and clients fall back to HTTP/2; verify with curl --http3, DevTools, or quic="h3" in the access log.

How I run this on my own infrastructure

jmrp.io serves HTTP/3 itself: both server blocks in production carry listen 443 quic; alongside the standard TCP listeners, so the site accepts QUIC and TLS-over-TCP connections side by side depending on what the client — and whatever sits between the client and the server — actually supports.

I didn’t take the Alt-Svc header’s word for it. Running curl --http3 straight against the live site returns HTTP/3 200: a completed QUIC handshake, not an inference drawn from a response header that a misconfigured server could still send while quietly falling back to HTTP/2 underneath. That distinction matters — plenty of “HTTP/3 enabled” checklists stop at the header and never confirm the protocol actually negotiated. It’s a small thing to verify, and it’s also the only way to know a QUIC deployment is real instead of aspirational: the header costs nothing to add, but only a client-side handshake proves UDP/443 is actually open and negotiating end to end.

Serving HTTP/3 and having it used are different questions, and only the second one needs data. jmrp.io sits behind Cloudflare, which terminates the client connection, so the protocol a visitor actually negotiates shows up in Cloudflare’s zone analytics rather than in my own access log. Over eight days — 19 to 27 August 2026, filtered to the jmrp.io host so the rest of the zone doesn’t drown it out — HTTP/3 carried 33.5% of the requests that negotiated either HTTP/2 or HTTP/3: 11,208 out of 33,411.

That denominator needs two sentences of defense, because the honest version of this measurement is mostly about what has to be thrown away. I excluded HTTP/1.1 entirely, and the exclusion is not cosmetic: current browsers settle on h2 through ALPN against Cloudflare, so a request that claims a browser user-agent and still arrives over HTTP/1.1 is almost always a scraper with a forged one. There were plenty of those; one “desktop Chrome” bucket came in at 45.8% HTTP/1.1, which is not something desktop Chrome does. Dropping HTTP/1.1 removes the obvious forgeries, but it cannot promise the rest are clean: the Free plan exposes no bot score, and the browser dimension is itself derived from the user-agent string, so no independent field is left to filter on. That is also why there is no adoption-by-browser table here — I’m not publishing per-browser percentages built on a denominator I can’t audit. The window is eight chained 24-hour queries, the longest range that plan will return, so it has eight two-minute seams in it: roughly 16 minutes missing in total.

Two things about that number. The first is that it is a snapshot of that window, not a standing property of the site. Run the same query over the seven complete UTC days from 26 August to 1 September 2026 and the share is 20.1% — 10,508 of 52,341 — and it swings from day to day:

HTTP/3 share per day — jmrp.io host, 26 August to 1 September 2026
Day (UTC)HTTP/3HTTP/2 + HTTP/3HTTP/3 share
2026-08-262,0139,04622.3%
2026-08-271,76510,45716.9%
2026-08-281,6307,14222.8%
2026-08-298826,90212.8%
2026-08-301,5446,42324.0%
2026-08-311,2726,77818.8%
2026-09-011,4025,59325.1%
Total10,50852,34120.1%

The second is that neither reading can be re-derived later. The Free plan retains httpRequestsAdaptiveGroups for a rolling eight days and refuses anything older with cannot request data older than 1w1d, so both figures are dated observations that the source itself will not serve again. The table is here for that reason, and it publishes both counts rather than only the daily percentage, because the percentages on their own would not be enough: the busiest day carries almost twice the traffic of the quietest, so the seven rounded shares cannot reconstruct the weighted 20.1% — their unweighted mean is 20.4%. With every numerator and denominator on the page, the total is arithmetic anyone can check.

One measured detail I did not expect. My origin does send the header this guide tells you to add — add_header Alt-Svc 'h3=":443"; ma=86400' always; sits in both TLS server blocks, and a request straight to the origin comes back carrying it. Through Cloudflare, that header is simply not there:

Alt-Svc at the origin vs. at the edge
On the origin host itself, bypassing the CDN entirely:$ curl -sS -kD- -o /dev/null -H 'Host: jmrp.io' https://127.0.0.1/
alt-svc: h3=":443"; ma=86400
$ curl -sS -D- -o /dev/null --resolve jmrp.io:443:104.21.40.251 https://jmrp.io/
server: cloudflare
(no alt-svc header in the response)
$ dig +short HTTPS jmrp.io @1.1.1.1
1 . alpn="h3,h2" ipv4hint=104.21.40.251,172.67.158.146 ech=…

QUIC works anyway, as the handshake above already showed, because the advertisement lives somewhere else: the DNS HTTPS record for the domain carries alpn="h3,h2", and browsers that resolve that record learn about HTTP/3 before they open a single connection. This is Cloudflare’s behavior across its platform, not a peculiarity of my configuration — the edge terminates the connection and decides for itself what to advertise, and nothing in my Nginx config can change that. The practical lesson is narrow but worth having: if a CDN sits in front of the origin you configured for HTTP/3, the header you are testing for may not be the one your visitors ever see. Check the DNS record too.


Why did QUIC replace TCP for HTTP/3?

The QUIC protocol has an interesting evolution from a proprietary Google experiment to a full IETF standard.

QUIC Timeline
YearMilestoneSignificance
2012Google begins QUIC developmentInternal project to reduce web latency
2013First QUIC traffic in ChromeEarly experiments with Google services
2014Wide-scale gQUIC deploymentChrome desktop uses QUIC for Google properties
2016IETF QUIC Working Group formedFormal standardization process begins
2017IETF QUIC diverges from gQUICTLS 1.3 integration, general-purpose transport
2021RFC 9000 publishedQUIC Transport Protocol standardized
2022RFC 9114 publishedHTTP/3 standardized

The QUIC RFC family

The complete QUIC specification spans multiple RFCs:

QUIC Standards Documents
RFCTitleDescription
RFC 9000QUIC TransportCore protocol: packets, frames, streams, connection management
RFC 9001Using TLS to Secure QUICTLS 1.3 integration, key derivation, encryption levels
RFC 9002Loss Detection and Congestion ControlPacket loss recovery, RTT estimation, congestion algorithms
RFC 8999Version-Independent PropertiesBehaviors common across all QUIC versions
RFC 9114HTTP/3HTTP semantics over QUIC

Why QUIC? Understanding TCP’s limitations

To appreciate QUIC, we must understand why TCP—the backbone of the internet since 1974—struggles with modern web demands.

What is head-of-line (HOL) blocking?

This is the fundamental issue that QUIC solves. Imagine a highway with a single lane (TCP connection). If one car breaks down (packet loss), everyone behind must stop and wait, even if they’re going to different destinations.

ClientNetworkServerClientNetworkServer**HTTP/2 over TCP: HOL Blocking**ALL streams blockedwaiting for retransmission**HTTP/3 over QUIC: Independent Streams**Only Stream A waitsB and C continue!Stream A: Packet 1Stream B: Packet 1Stream A: Packet 2 (LOST)Stream C: Packet 1Stream A: Packet 1Stream B: Packet 1 (BLOCKED!)(Waiting for Stream A: Packet 2...)Stream A: Packet 1Stream B: Packet 1Stream A: Packet 2 (LOST)Stream C: Packet 1Stream A: Packet 1Stream B: Packet 1 (Delivered!)Stream C: Packet 1 (Delivered!)
Head-of-Line Blocking: TCP vs QUIC

The handshake latency problem

Establishing a secure TCP connection requires multiple round trips:

Connection Establishment Comparison
ProtocolNew ConnectionResumed ConnectionTotal RTTs
TCP + TLS 1.2TCP handshake + TLS handshakeTCP + abbreviated TLS3 RTT → 2 RTT
TCP + TLS 1.3TCP handshake + TLS 1.3TCP + TLS 0-RTT2 RTT → 1 RTT
QUICCombined handshake0-RTT early data1 RTT → 0 RTT

The connection migration problem

TCP connections are identified by a 4-tuple: (source IP, source port, destination IP, destination port). When you switch from Wi-Fi to mobile data, your IP changes—and your TCP connection breaks.


QUIC architecture deep dive

Transport over UDP

QUIC is built on UDP rather than creating a new IP protocol. This was a pragmatic choice:

  • No kernel changes required: UDP is universally supported
  • Userspace implementation: Faster iteration and deployment
  • Middlebox traversal: UDP passes through most NATs and firewalls

Packet structure

QUIC uses two packet types:

QUIC Packet Types
TypeHeaderUsed ForEncryption
Long HeaderFull connection infoInitial, Handshake, 0-RTT, RetryLevel-specific keys
Short HeaderMinimal (post-handshake)Application data (1-RTT)Application keys

Streams and flow control

QUIC streams are lightweight, multiplexed channels within a connection:

No

Yes

0 (Even)

1 (Odd)

0 (Bidi)

1 (Uni)

0 (Bidi)

1 (Uni)

No

Yes

Incoming Stream Frame

Connection MAX_DATA Limit OK?

Connection Blocked

Decode Stream ID

Last Bit: Initiator?

Client-Initiated

Server-Initiated

2nd Last Bit: Direction?

2nd Last Bit: Direction?

Type 0x0: Client Bidi

Type 0x2: Client Uni

Type 0x1: Server Bidi

Type 0x3: Server Uni

Stream MAX_STREAM_DATA Limit OK?

Stream Blocked

Receive Buffer

QUIC Stream Processing Logic

Stream ID Decoding: The Stream ID is a 62-bit integer where the least significant 2 bits function as a type header:

  • Bit 0 (Initiator): 0 = Client, 1 = Server
  • Bit 1 (Direction): 0 = Bidirectional, 1 = Unidirectional

This creates 4 distinct address spaces:

  • …00: Client Request/Response
  • …01: Server Push (Deprecated)
  • …10: Client Control Stream
  • …11: Server Control Stream (QPACK)

Dual-Layer Flow Control: For a packet to be processed, it must pass two independent checks:

  1. Connection-Level: Is there MAX_DATA credit for the entire connection?
  2. Stream-Level: Is there MAX_STREAM_DATA credit for this specific stream? If either is exhausted, transmission blocks until a WINDOW_UPDATE frame arrives.

The QUIC handshake: speed meets security

QUIC combines transport and cryptographic handshakes into a single exchange, dramatically reducing latency.

1-RTT handshake (first connection)

ServerClientServerClient**QUIC 1-RTT Handshake**Contains SCID, DCID, versionServer sends application data keysCan send HTTP request immediately!Total: 1 Round Trip to first dataInitial PacketClientHello + QUIC Transport ParamsInitial + Handshake PacketsServerHello + Cert + FinishedHandshake Complete + First RequestResponse Data
QUIC 1-RTT Handshake

0-RTT handshake (resumed connection)

For clients that have connected before, QUIC allows sending encrypted data in the very first packet:

ServerClientServerClient**QUIC 0-RTT (Resumed Connection)**Data sent before handshake completes!Total: 0 Round Trips to send requestInitial + 0-RTT PacketsClientHello + Early Data (HTTP GET)Initial + Handshake + 1-RTTServerHello + Cert + Response Data
QUIC 0-RTT Resumption

Security architecture

QUIC’s security is not optional—it’s built into the protocol from the ground up.

Four encryption levels

QUIC Encryption Levels
LevelKeys Derived FromUsed ForForward Secrecy
InitialDestination Connection IDFirst packets, version negotiationNo
0-RTTPre-shared key (PSK)Early application dataNo
HandshakeTLS handshake secretsHandshake completionPartial
Application (1-RTT)TLS traffic secretsAll post-handshake dataFull (ECDHE)

Packet protection

Every QUIC packet (except Initial) is protected with AEAD encryption:

QUIC Packet Layout & Protection
Packet SectionComponentProtection Level
HeaderFlagsProtected (Header Protection)
Connection IDCleartext (for routing)
Packet NumberEncrypted (Header Protection)
PayloadApplication DataFully Encrypted (AEAD)
FooterAuthentication Tag16-byte Integrity MAC

Connection ID and privacy

DoS protection mechanisms

QUIC includes several anti-amplification and anti-spoofing measures:

  1. Address Validation: Servers send RETRY packets to validate client addresses
  2. Anti-Amplification: Servers limit data sent before address validation (3x client data)
  3. Stateless Reset: Clean connection termination without state

HTTP/3: HTTP over QUIC

HTTP/3 is the mapping of HTTP semantics onto QUIC transport. It replaces HTTP/2’s binary framing with QUIC streams.

HTTP/2 vs HTTP/3
FeatureHTTP/2HTTP/3
TransportTCP + TLSQUIC (UDP + TLS 1.3)
MultiplexingStream frames in single connectionNative QUIC streams
HOL BlockingYes (at TCP layer)No (independent streams)
Header CompressionHPACKQPACK (handles out-of-order)
Server PushSupportedDeprecated (rarely used)
Connection MigrationNoYes

Who uses HTTP/3 today?

As of 2026, HTTP/3 adoption is significant and growing:

HTTP/3 Adoption Statistics
MetricValueSource
Websites using HTTP/336.6%W3Techs (2026)
Chrome QUIC usage (subsequent)40%APNIC Labs
Page load improvement (global)12.4%Cloudflare Benchmarks
High-latency region improvement13.8%DebugBear

Nginx configuration: complete guide

Now let’s implement HTTP/3 on Nginx. This section covers everything from prerequisites to production deployment.

Prerequisites

Verify your Nginx installation:

nginx -V 2>&1 | grep -E 'version|http_v3_module|OpenSSL|BoringSSL'
Nginx version output
nginx version: nginx/1.28.0
built with OpenSSL 3.5.0 8 Apr 2025 (running with OpenSSL 3.5.4 30 Sep 2025)
configure arguments: … --with-http_v3_module …
How to install Nginx with HTTP/3 support

The packages nginx.org publishes for Debian and Ubuntu are built --with-http_v3_module against OpenSSL 3.5, so HTTP/3 needs no custom build. The same commands work on both distributions — /etc/os-release supplies the vendor and the codename.

sudo apt install curl gnupg2 ca-certificates
DISTRO=$(. /etc/os-release && echo "$ID")
CODENAME=$(. /etc/os-release && echo "$VERSION_CODENAME")

# Nginx signing key in its own keyring
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://nginx.org/keys/nginx_signing.key -o /tmp/nginx.key

# Check the fingerprints against the list nginx publishes at
# https://nginx.org/en/pgp_keys.html BEFORE trusting the key. The bundle
# carries several keys and they rotate, so compare the output rather than
# a number copied from a blog post.
gpg --show-keys --with-fingerprint /tmp/nginx.key

sudo gpg --dearmor -o /etc/apt/keyrings/nginx.gpg /tmp/nginx.key

# Mainline repository, restricted to that keyring
echo "deb [signed-by=/etc/apt/keyrings/nginx.gpg] https://nginx.org/packages/mainline/$DISTRO $CODENAME nginx" \
    | sudo tee /etc/apt/sources.list.d/nginx.list

sudo apt update && sudo apt install nginx

# Confirm the build you just installed
nginx -V 2>&1 | tr ' ' '\n' | grep -E 'http_v3|OpenSSL'

Only needed if you want a module the packages don’t carry. Nginx builds HTTP/3 against any SSL library exposing a QUIC API: OpenSSL 3.5+, BoringSSL or LibreSSL 3.6+. On a current distribution the system OpenSSL is enough — the official Debian 13 package is itself built against OpenSSL 3.5.6.

openssl version   # 3.5.0 or newer

wget https://nginx.org/download/nginx-1.31.4.tar.gz
tar xzf nginx-1.31.4.tar.gz
cd nginx-1.31.4

./configure \
    --with-http_v3_module \
    --with-http_ssl_module \
    --with-http_v2_module

make -j"$(nproc)"
sudo make install

Basic configuration

Here’s the minimal configuration to enable HTTP/3:

/​etc/​nginx/​sites-available/​example.com.conf
server {
    server_name example.com;
    root /var/www/example.com;

    # ==========================================
    # LISTENERS: TCP (HTTP/1.1, HTTP/2) + UDP (HTTP/3)
    # ==========================================
    
    # Standard HTTPS over TCP
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    
    # QUIC/HTTP/3 over UDP
    # 'reuseport' is critical for multi-worker performance
    listen 443 quic reuseport;
    listen [::]:443 quic reuseport;

    # ==========================================
    # SSL/TLS CONFIGURATION
    # ==========================================
    
    # TLS 1.3 is REQUIRED for QUIC
    ssl_protocols TLSv1.3 TLSv1.2;
    ssl_prefer_server_ciphers off;
    
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # ==========================================
    # HTTP/3 CONFIGURATION
    # ==========================================
    
    http3 on;
    
    # Advertise HTTP/3 support to browsers
    # ma=86400 means "cache this info for 24 hours"
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    location / {
        try_files $uri $uri/ =404;
    }
}

Complete production configuration

Here’s a comprehensive configuration with all optimizations. The configuration is split into two files: the main nginx.conf for global settings and a site-specific configuration file.

/​etc/​nginx/​nginx.conf 42 lines
# Main context
worker_processes auto;
error_log /var/log/nginx/error.log warn;

events {
    worker_connections 4096;
    use epoll;
    multi_accept on;
}

http {
    # ==========================================
    # HTTP/3 GLOBAL SETTINGS
    # ==========================================
    # Explicit for clarity (http3 defaults to 'on' in nginx 1.25.0+)
    http3 on;
    
    # Custom log format to track HTTP/3 connections
    log_format quic '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent" '
                    'proto="$server_protocol" quic="$http3"';

    # MIME types
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    # Performance optimizations
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    
    # Gzip compression
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css application/json application/javascript 
               text/xml application/xml application/xml+rss text/javascript;

    # Include site configurations
    include /etc/nginx/sites-enabled/*;
}

The site-specific configuration includes all the directives for QUIC, TLS, headers, and logging:

/​etc/​nginx/​sites-available/​example.com.conf 105 lines
server {
    server_name example.com www.example.com;
    root /var/www/example.com;

    # ==========================================
    # DUAL-STACK LISTENERS
    # ==========================================
    
    # TCP: HTTP/1.1 and HTTP/2 fallback
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    
    # UDP: QUIC/HTTP/3
    listen 443 quic reuseport;
    listen [::]:443 quic reuseport;

    # ==========================================
    # TLS CONFIGURATION (Required for QUIC)
    # ==========================================
    
    ssl_protocols TLSv1.3 TLSv1.2;
    # TLS 1.3 ciphersuites (handled separately for better OpenSSL compatibility)
    ssl_conf_command Ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;
    # TLS 1.2 ciphers only (ECDHE for forward secrecy)
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;
    ssl_ecdh_curve X25519:P-256:P-384;
    
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    
    # Session resumption for performance
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;  # Better security
    
    # No OCSP stapling. Let's Encrypt removed the OCSP URL from its
    # certificates in May 2025 and shut its responders down in August 2025,
    # so with this certificate ssl_stapling only logs "no OCSP responder URL"
    # and does nothing. Keep it only for a CA that still runs OCSP.

    # ==========================================
    # QUIC-SPECIFIC SETTINGS
    # ==========================================
    
    # Enable 0-RTT early data (with security considerations)
    ssl_early_data on;
    
    # DoS protection: require address validation
    quic_retry on;
    
    # Performance: Generic Segmentation Offload (Linux 4.18+)
    quic_gso on;

    # ==========================================
    # HEADERS
    # ==========================================
    
    # Advertise HTTP/3 availability
    add_header Alt-Svc 'h3=":443"; ma=86400' always;
    
    # Warn backends about 0-RTT replay risk
    add_header Early-Data $ssl_early_data always;
    
    # Security headers
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;

    # ==========================================
    # LOGGING
    # ==========================================
    
    access_log /var/log/nginx/example.com.access.log quic;
    error_log /var/log/nginx/example.com.error.log;

    # ==========================================
    # LOCATIONS
    # ==========================================
    
    location / {
        try_files $uri $uri/ =404;
    }
    
    # Static assets with long cache
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable" always;
        add_header Alt-Svc 'h3=":443"; ma=86400' always;
        
        # Security headers (inherited from server block may not apply with add_header in location)
        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-Frame-Options "DENY" always;
    }
}

# HTTP to HTTPS redirect
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

All QUIC directives reference

Nginx QUIC Directives
DirectiveDefaultContextDescription
http3onhttp, serverEnables HTTP/3 protocol negotiation
http3_hqoffhttp, serverEnables HTTP/0.9 over QUIC (testing only)
http3_max_concurrent_streams128http, serverMaximum concurrent streams per connection
http3_stream_buffer_size64khttp, serverBuffer size for reading/writing streams
quic_active_connection_id_limit2http, serverMax connection IDs stored per connection
quic_bpfoffmaineBPF routing for connection migration (Linux 5.7+)
quic_gsooffhttp, serverGeneric Segmentation Offload (Linux 4.18+)
quic_host_key-http, serverFile with secret key for address validation tokens
quic_retryoffhttp, serverEnables address validation via Retry packets

Firewall configuration

Critical: HTTP/3 uses UDP on port 443, not TCP. If your firewall only allows TCP/443, QUIC will fail silently and clients will fall back to HTTP/2.

# Check current rules
sudo ufw status

# Allow UDP on port 443
sudo ufw allow 443/udp comment 'QUIC/HTTP3'

# Verify
sudo ufw status verbose
# Allow incoming UDP 443
sudo iptables -A INPUT -p udp --dport 443 -j ACCEPT

# Save rules (Debian/Ubuntu)
sudo iptables-save | sudo tee /etc/iptables/rules.v4

# For IPv6
sudo ip6tables -A INPUT -p udp --dport 443 -j ACCEPT
sudo ip6tables-save | sudo tee /etc/iptables/rules.v6
# Add UDP 443 to default zone
sudo firewall-cmd --permanent --add-port=443/udp

# Reload
sudo firewall-cmd --reload

# Verify
sudo firewall-cmd --list-ports

Add an inbound rule:

  • Type: Custom UDP
  • Port Range: 443
  • Source: 0.0.0.0/0 (or your CIDR)
  • Description: QUIC/HTTP3

Kernel tuning for high traffic

For high-traffic servers, increase UDP buffer sizes:

/​etc/​sysctl.d/​99-quic.conf
# Increase UDP buffer sizes for QUIC
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.core.rmem_default = 1048576
net.core.wmem_default = 1048576

# UDP memory limits
net.ipv4.udp_mem = 65536 131072 262144
net.ipv4.udp_rmem_min = 8192
net.ipv4.udp_wmem_min = 8192

# Allow more local ports for connections
net.ipv4.ip_local_port_range = 1024 65535

Apply the settings:

sudo sysctl --system

Verification and testing

After configuration, verify HTTP/3 is working correctly.

Method 1: curl

Modern curl (7.66+ with HTTP/3 support) can test directly:

# Test HTTP/3 specifically
curl -I --http3-only https://jmrp.io
# Or allow fallback
curl -I --http3 https://jmrp.io
Curl HTTP/3 response output
HTTP/3 200
server: jmrp.io
date: Wed, 14 Jan 2026 20:02:32 GMT
content-type: text/html; charset=utf-8
alt-svc: h3=":443"; ma=86400
strict-transport-security: max-age=63072000; includeSubDomains; preload

Method 2: browser DevTools

  1. Open Chrome or Firefox
  2. Navigate to your site
  3. Open DevTools (F12) → Network tab
  4. Right-click column headers → Enable Protocol column
  5. Reload the page
  6. Look for h3 in the Protocol column

Method 3: online tools

HTTP/3 Testing Tools
ToolURLFeatures
HTTP/3 Checkhttp3check.netQuick pass/fail test with details
Cloudflare HTTP/3 Testcloudflare-quic.comLive connection test
Qualys SSL Labsssllabs.comComprehensive TLS analysis

Method 4: check Nginx logs

Use the custom log format to verify HTTP/3 traffic:

# Check for HTTP/3 connections
grep 'quic="h3"' /var/log/nginx/access.log | tail -5
Log output showing HTTP/3 traffic
192.168.1.100 - - [14/Jan/2026:20:02:32 +0000] "GET / HTTP/3" 200 15234 "-" "Mozilla/5.0…" proto="HTTP/3" quic="h3"

Troubleshooting guide

Common HTTP/3 Issues and Solutions
SymptomLikely CauseSolution
Protocol stays HTTP/2Firewall blocking UDP/443Open UDP port 443 on server and cloud provider
Protocol stays HTTP/2Missing Alt-Svc headerAdd add_header Alt-Svc 'h3=":443"; ma=86400' always;
Connection errorsTLS 1.3 not enabledEnable TLSv1.3 for QUIC (required); TLSv1.2 may remain enabled for HTTP/2 fallback
Browsers refuse to use QUICSelf-signed certificateUse a valid certificate (Let’s Encrypt)
Nginx fails to startMissing --with-http_v3_moduleRebuild Nginx with HTTP/3 module
High CPU usageGSO not supported by kernelDisable quic_gso or upgrade kernel
0-RTT not workingOpenSSL version too oldUse OpenSSL 3.5.1+, QuicTLS, or BoringSSL

Debug mode

Enable debug logging temporarily:

nginx
error_log /var/log/nginx/error.log debug;

Check for QUIC-specific messages:

grep -i quic /var/log/nginx/error.log | tail -20
Nginx debug log (simulated failure)
2026/01/14 10:42:15 [debug] 12345#0: *1 quic handle packet: fd:16, addr:192.0.2.1:54321
2026/01/14 10:42:15 [debug] 12345#0: *1 quic packet rx dcid len:8 87654321
2026/01/14 10:42:15 [debug] 12345#0: *1 quic packet rx scid len:8 12345678
2026/01/14 10:42:15 [info] 12345#0: *1 quic SSL_do_handshake() failed: SSL_ERROR_SSL: error:14094416:SSL routines:ssl3_read_bytes:sslv3 alert certificate unknown
2026/01/14 10:42:15 [debug] 12345#0: *1 quic close connection: 0:

Production deployment checklist

Before deploying HTTP/3 to production, verify:

HTTP/3 Production Checklist
CategoryItemVerification
BuildNginx compiled with --with-http_v3_modulenginx -V 2>&1 | grep http_v3
BuildCompatible SSL library (QuicTLS/BoringSSL/OpenSSL 3.5.1+)nginx -V 2>&1 | grep -i ssl
NetworkUDP/443 open on server firewallsudo ss -ulnp | grep 443
NetworkUDP/443 open on cloud provider (AWS/GCP/Azure)Check security group/firewall rules
Configlisten 443 quic reuseport; presentnginx -T | grep quic
ConfigTLS 1.3 enablednginx -T | grep ssl_protocols
ConfigAlt-Svc header configuredcurl -I https://example.com | grep alt-svc
CertificateValid (not self-signed) certificateopenssl s_client -connect site:443
TestHTTP/3 confirmed workingcurl --http3 https://example.com
MonitoringLog format includes $http3Check log format configuration

When should you not use QUIC?

While QUIC offers significant benefits, there are scenarios where TCP may be preferable:

Frequently asked questions

What do I need to enable HTTP/3 in Nginx?

You need Nginx 1.25.0 or newer (HTTP/3 support was added mid-2023) compiled with the --with-http_v3_module, plus an SSL library with QUIC support such as OpenSSL 3.5.1+, BoringSSL, QuicTLS, or LibreSSL 3.6+. TLS 1.3 is required for QUIC.

Does HTTP/3 use TCP or UDP?

HTTP/3 runs over QUIC, which is built on UDP rather than TCP. You must open UDP port 443 on both the server firewall and any cloud provider firewall, or QUIC fails silently and clients fall back to HTTP/2.

How does QUIC solve TCP's head-of-line blocking?

TCP treats everything as one ordered byte stream, so a single lost packet blocks all multiplexed HTTP/2 streams until it is retransmitted. QUIC implements streams at the transport layer, giving each stream independent delivery, so a lost packet only affects its own stream.

Why does my browser still show HTTP/2 on the first visit?

Seeing HTTP/2 on a first visit is expected: a browser that has never connected to the origin has no way of knowing it speaks HTTP/3, so it opens a TCP connection first. During that request the browser learns about HTTP/3 — from the Alt-Svc response header, or from a DNS HTTPS record advertising alpn=h3 — and switches to HTTP/3 on subsequent requests or after a reload.

Why is the reuseport flag critical for QUIC in Nginx?

UDP is connectionless, so without reuseport all QUIC packets are handled by a single Nginx worker, creating a bottleneck. The reuseport kernel feature distributes packets across workers, but it must appear on exactly one listen directive per address:port globally or Nginx fails to start.

How do I verify HTTP/3 is working?

Use modern curl with curl -I --http3 https://yoursite, enable the Protocol column in browser DevTools and look for h3, use online tools like http3check.net or cloudflare-quic.com, or grep your Nginx access log for quic="h3".

Further Reading & Resources

  1. RFC 9000 IETF
  2. RFC 9001 IETF
  3. RFC 9002 IETF
  4. RFC 8999 IETF
  5. RFC 9114 IETF
  6. W3Techs (2026) w3techs.com
  7. APNIC Labs blog.apnic.net
  8. Cloudflare Benchmarks blog.cloudflare.com
  9. DebugBear debugbear.com
  10. http3 Nginx
  11. http3_hq Nginx
  12. http3_max_concurrent_streams Nginx
  13. http3_stream_buffer_size Nginx
  14. quic_active_connection_id_limit Nginx
  15. quic_bpf Nginx
  16. quic_gso Nginx
  17. quic_host_key Nginx
  18. quic_retry Nginx
  19. http3check.net http3check.net
  20. cloudflare-quic.com cloudflare-quic.com
  21. ssllabs.com Qualys SSL Labs