Servers Tutorials • • 5-8 minutes

HTTP/3 on Nginx: Turn On QUIC and Prove the Browser Is Actually Using It

Diego Cortés
Diego Cortés
Full Stack Developer & SEO Specialist
Share:
HTTP/3 on Nginx: Turn On QUIC and Prove the Browser Is Actually Using It
Image generated with AI

You have HTTP/2, a valid certificate and a VPS that runs perfectly fine, and your site still negotiates over TCP while your users' phones drop packets. HTTP/3 on Nginx takes ten lines; the catch is three steps that don't fail loudly when you skip them — they just do nothing.

What HTTP/3 Actually Changes

HTTP/3 isn't a faster version of HTTP/2 on the same transport: it replaces the transport. It runs on QUIC, which travels in UDP datagrams and moves much of what the network used to do into the client.

QUIC over UDP: Connections Stop Depending on Packet Order

In TCP, if a packet is lost everything behind it waits even when it already arrived: that is head-of-line blocking. QUIC numbers streams separately, so a lost packet stalls its own stream and not the others. On a lossy network — mobile, hotel wifi, a train — it shows; on stable fibre, barely.

TLS 1.3 Is Mandatory, and Why 0-RTT Should Stay Off

QUIC builds encryption into the protocol: there is no HTTP/3 without TLS, and the only valid TLS is 1.3. The handshake and the cryptographic negotiation travel together, saving a round trip compared with TCP plus TLS. 0-RTT lets a returning client send data in the first packet, but that early data can be replayed, so it is only safe for idempotent requests.

Step 0: Check Whether Your Nginx Has the Module

This is the step almost everyone skips and the one that produces the most confusing error: the configuration is valid, but nginx refuses to start because the binary has no idea what QUIC means.

nginx -V and Hunting for --with-http_v3_module

The module is ngx_http_v3_module, it has existed since 1.25.0 and it is not compiled by default: you enable it at build time with --with-http_v3_module.

nginx -v
nginx -V 2>&1 | tr ' ' '\n' | grep -i http_v3

If the second command prints the flag, you have support. If it prints nothing, the module is missing and no listen ... quic will work.

If It's Missing: Official Packages or a Rebuild

Distribution packages usually ship without the module. The fast way out is the official nginx.org repository, which includes it; the other is rebuilding with the flag, but then you own the security updates yourself. The current branches are 1.28.x (stable) and 1.29.x (mainline): check yours with nginx -v instead of assuming a number.

Step 1: The Minimal Config That Works

server {
    listen 443 ssl;
    listen 443 quic reuseport;

    http2 on;

    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # The header that makes the browser discover h3.
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    quic_retry on;

    root /var/www/example.com/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
}

listen 443 ssl and listen 443 quic in the Same Server Block

Both are required: one serves clients arriving over TCP (HTTP/1.1 and HTTP/2), the other serves clients arriving over UDP with QUIC. Remove the TCP one and every browser without support, plus every crawler, is left outside.

http2 on and reuseport: The Two Lines People Forget

Since 1.25.1, HTTP/2 is enabled with http2 on; and no longer with the http2 parameter inside listen: copy an old config and HTTP/2 may end up disabled without anyone noticing. reuseport is what lets the kernel spread datagrams across several workers; without it a single process handles all QUIC traffic, or the config refuses to start.

Step 2: Alt-Svc, the Header That Does the Work

Here is the most common misunderstanding: having QUIC listening on UDP 443 does not make anyone use it. The browser arrives over TCP, reads the response, and only if it finds Alt-Svc does it try HTTP/3 on the next connection.

h3=":443" and the ma=86400 Nobody Explains

h3=":443" announces that the same origin is available over HTTP/3 on port 443. ma is max-age in seconds: how long the browser remembers it. 86400 is 24 hours; too high and clients keep trying h3 after you turned QUIC off, too low and every visit has to rediscover it.

Where add_header Goes

At server level, not inside a location. If you declare any add_header in a location, the server-level ones stop being inherited there: those pages go out without Alt-Svc and the browser never migrates. Confirm with curl -sI.

Multiple Domains on One Server

You can only have one listen ... quic reuseport per address and port. With ten domains on the same IP, the first carries reuseport and the rest only quic; repeat the flag and nginx rejects the configuration on reload.

Step 3: Open the Path for UDP

The firewall is the third step that fails silently: TCP has been open since day one, UDP is not, and QUIC traffic gets dropped with no visible error.

ufw, nftables and the 443/udp Port Everyone Forgets

sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose

If the server sits behind a cloud provider firewall, open it in that console too, not just on the machine.

Docker: Publish 443/udp Alongside TCP

docker run -d -p 443:443/tcp -p 443:443/udp nginx:mainline

In Docker, -p 443:443 publishes TCP only. The symptom is misleading: the site works, Alt-Svc shows up in the response, and the browser never manages to speak h3.

Step 4: Verify It's Really HTTP/3

Seeing alt-svc in the response only proves you are advertising it. The real test is forcing h3 and reading back the protocol the server answered with.

curl --http3 vs --http3-only: The Difference Matters

curl --version | grep HTTP3
curl -sI https://example.com | grep -i alt-svc
curl --http3-only -s -o /dev/null -w '%{http_version}\n' https://example.com

--http3 attempts h3 and falls back to earlier versions when it fails, so a clean result proves nothing: it may have come in over TCP. --http3-only demands h3 and fails otherwise. A 3 in the output is the confirmation.

Nginx Logs with $server_protocol

log_format proto '$remote_addr "$request" $status proto=$server_protocol';
access_log /var/log/nginx/proto.log proto;

If lines with proto=HTTP/3.0 show up while browsing, the whole path works. If every line says HTTP/2.0, the header isn't reaching the client or UDP is blocked.

tcpdump: Watch UDP 443 Actually Leave the Box

sudo tcpdump -ni any udp port 443 -c 10

With that running, open the site from the browser. No packets at all means the problem is upstream of the server: provider firewall, NAT or a network filtering UDP.

Where It Breaks (and If You Have a CDN in Front)

Networks That Block UDP and the Fallback Myth

Plenty of corporate networks and some carriers filter UDP on 443. HTTP/3 is designed for that: the client tries, fails, falls back to TCP and the user notices nothing. That is exactly why the listen 443 ssl line never gets removed.

MTU and Non-Idempotent Requests

QUIC avoids in-path fragmentation, but if an intermediate device drops the MTU discovery packets, the connection sits there retrying: the classic "works at home, fails at a client's office" cause. And enabling ssl_early_data on without understanding replay means a POST can run twice: in a store, a double charge.

With a CDN, You Don't Configure This

With Cloudflare or another CDN, the visitor speaks HTTP/3 to the CDN and the CDN speaks whatever it likes to your origin. Enabling QUIC on your origin nginx does not change what the user sees.

Does My Site Gain Anything? How to Measure Without Fooling Yourself

Where It Shows and Where It Doesn't

The gain appears on new connections and unstable networks: a visit from a phone on mediocre coverage saves the handshake and doesn't stall on the first lost packet. It does not show in the backend: QUIC terminates at nginx, traffic to PHP-FPM is still FastCGI and the database is still the database. If your TTFB is set by a slow query, HTTP/3 won't fix it.

What to Measure Before and After

Measure first-visit load time on mobile with an empty cache, before and after, and compare percentiles rather than averages. What you cannot promise is a percentage: there is no universal benchmark and every network behaves differently.

Conclusion

HTTP/3 is cheap to enable and easy to break silently. The gap between "I turned it on" and "it works" is three checks: the module is compiled in, Alt-Svc comes back in the response, and curl --http3-only returns a 3. The blog also has the Laravel deploy guide with Nginx and the page cache guide for lowering TTFB, which attack the other end of the same problem.

Categories