Step-by-Step: Nginx + HTTP/3 (QUIC) Setup and Testing

Step-by-Step: Nginx + HTTP/3 (QUIC) Setup and Testing
11/19/2025 •

Introduction

HTTP/3 brings the web onto QUIC, a secure, multiplexed transport over UDP that shrugs off head-of-line blocking and slashes connection setup time. For engineers and builders, running your own HTTP/3 edge with Nginx is the fastest way to understand how QUIC behaves in the real world—packet by packet, handshake by handshake.

In this lab, you’ll stand up an HTTP/3-capable Nginx on Ubuntu, serve real content over UDP :443, and keep classic HTTPS (HTTP/1.1/2 over TCP :443) as a fallback. You’ll also learn how to advertise H3 to clients (Alt-Svc), validate with CLI tools and browsers, and read logs that tell you whether traffic used HTTP/3. A thorough troubleshooting section helps you diagnose the usual pitfalls—like forgetting to open UDP or using an Nginx build without the HTTP/3 module.

Step-by-Step: Nginx + HTTP/3 (QUIC) Setup and Testing

What you’ll set up

  • Nginx mainline with the HTTP/3 (http_v3) module enabled
  • Dual listeners: UDP 443 (QUIC/H3) and TCP 443 (TLS/H2/H1)
  • A minimal server block that serves a test page and /health
  • Alt-Svc header so clients upgrade to H3
  • Firewall rules for UDP and TCP
  • Practical tests (curl/ngtcp2/browsers) and log signals ($http3)
  • Optional QUIC features: retry, 0-RTT, GSO, and stable host keys

Prerequisites

  • Ubuntu 22.04/24.04/25.04 (sudo access)
  • A publicly reachable host (or localhost for lab)
  • A certificate (self-signed for lab, or real cert for your domain)

By the end, you’ll have a clean, reproducible HTTP/3 edge you can extend into a reverse proxy for APIs or web apps—complete with verification steps and fixes for the common gotchas.


Prereqs

      sudo apt update
sudo apt install -y curl gnupg2 ca-certificates lsb-release ubuntu-keyring ufw

    

1) Install an HTTP/3-capable Nginx (mainline)

Use the nginx.org repo so you get a build that includes the HTTP/3 module.

      # import nginx.org signing key
curl -fsSL https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
 | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null

# add MAINLINE repo (recommended for HTTP/3 features)
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
http://nginx.org/packages/mainline/ubuntu $(lsb_release -cs) nginx" \
 | sudo tee /etc/apt/sources.list.d/nginx.list

# prefer nginx.org packages
echo -e "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" \
 | sudo tee /etc/apt/preferences.d/99nginx

sudo apt update
sudo apt install -y nginx

    

Verify the build has HTTP/3:

      nginx -V 2>&1 | grep -E 'http_v3|TLSv1.3|OpenSSL'
    

You should see –with-http_v3_module. (HTTP/3 is available since 1.25 and included in Linux binary packages.)

Pointer: Nginx’s HTTP/3 requires TLS 1.3 (enabled by default). For 0-RTT you need OpenSSL 3.5.1+ or a QUIC-capable TLS like BoringSSL/QuicTLS/LibreSSL.


2) Create a certificate (lab: self-signed)

      sudo openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout /etc/nginx/key.pem -out /etc/nginx/cert.pem \
  -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1"

    

Pointer: For a real domain, use a proper cert (e.g., ACME). Browsers will otherwise complain; for CLI tests you can use -k/–insecure.


3) Minimal HTTP/3 server block

Create /etc/nginx/conf.d/h3lab.conf:

      nano /etc/nginx/conf.d/h3lab.conf
    
      server {
    # QUIC / HTTP/3 over UDP 8443
    listen 8443 quic reuseport;
    listen [::]:8443 quic reuseport;

    # HTTPS (HTTP/1.1 + HTTP/2) over TCP 8443
    listen 8443 ssl;
    listen [::]:8443 ssl;
    http2 on;

    server_name localhost;

    ssl_certificate     /etc/nginx/cert.pem;
    ssl_certificate_key /etc/nginx/key.pem;
    ssl_protocols TLSv1.3;

    # Advertise H3 on 8443
    add_header Alt-Svc 'h3=":8443"; ma=86400' always;

    root /usr/share/nginx/html;
    index index.html;
    location /health { return 200 "ok\n"; }
}

    

Then:

      sudo nginx -t 
    
      sudo systemctl reload nginx 
    

If service failed to start then disable the Apache2 as it is using default 80 and 443 port.

      sudo systemctl stop apache2
sudo systemctl disable apache2
    

Start the Nginx service again

      sudo nginx -t
sudo systemctl start nginx
    
      systemctl status nginx -l --no-pager
    

Pointers (why this works):

  • listen … quic enables HTTP/3/QUIC on that port; reuseport helps distribute QUIC across multiple workers.
  • Keep both UDP:443 (H3) and TCP:443 (H2/H1) for fallback.
  • Alt-Svc tells clients that H3 is available on 443. (Clients typically use H2 first, then upgrade to H3 on subsequent requests.)

4) Open the firewall (don’t skip UDP!)

      sudo ufw allow 8443/tcp
sudo ufw allow 8443/udp
sudo ufw status | grep 8443

    

Pointer: Many “H3 didn’t work” cases are just UDP 8443 closed.

      ss -t -lpn | grep ':8443'
ss -u -lpn | grep ':8443'
    

5) First tests (CLI + browser)

A) Quick CLI check (curl)

If your curl supports HTTP/3:

      curl -I --http2 -k https://localhost:8443/
    

if curl has h3:

      curl -I --http3 -k https://localhost:8443/
    

If curl lacks H3: Nginx recommends starting with a simple console client like ngtcp2 to validate QUIC/H3 before trying browsers.

Please refer below article :

https://sanchitgurukul.com/test-nginx-http-3-with-ngtcp2

B) Use your browser (Chrome/Firefox)

  1. Visit https://localhost:8443/ and accept the self-signed cert.
  2. Reload once more (Alt-Svc lets the browser upgrade).
  3. Open DevTools → Network → add the Protocol column → you should see h3.

C) Confirm on the server (log whether it’s H3)

Add a tiny log format using $http3:

Example placement in /etc/nginx/nginx.conf

      nano /etc/nginx/nginx.conf
    
      http {
    ##
    # Logging format
    ##
    log_format quic '$remote_addr - $host "$request" $status h3=$http3';

    ##
    # Global access log using custom format
    ##
    access_log /var/log/nginx/access.log quic;

    server {
        listen 80;
        server_name sanchitgurukul.com;

        location / {
            root /var/www/html;
            index index.html;
        }
    }
}

    

🔹 Key Points

  1. log_format must go inside http {}, not inside server or location.
  2. access_log can use that format anywhere below http {}.
    • If you put it inside http {}, it applies globally.
    • If you put it inside server {}, it applies only to that server.
    • If you put it inside location {}, it applies only there.
  3. The variable $http3 is available only if Nginx is built with QUIC/HTTP3 support (like --with-http_v3_module and OpenSSL/quictls).
      # /etc/nginx/nginx.conf (http { ... })
log_format quic '$remote_addr - $host "$request" $status h3=$http3';
access_log /var/log/nginx/access.log quic;

    

Reload and watch logs:

      sudo nginx -t && sudo systemctl reload nginx
    
      tail -f /var/log/nginx/access.log
    

$http3 is set to h3 for HTTP/3, hq for hq, or empty otherwise.


Troubleshooting (fast path)

Symptom → Fix

  • H3 never happens (browser only shows h2)
    • Open UDP 443 on host + cloud SG.
    • Ensure config has both listen 443 quic and listen 443 ssl http2.
    • Clear cache / new tab; Alt-Svc needs a follow-up request.
  • curl: option –http3: is unknown
    • Your curl lacks H3. Use a build with ngtcp2/quiche or validate with ngtcp2 h3 client first (as Nginx suggests).
  • Nginx errors on quic or no H3 at all
    • Check nginx -V for –with-http_v3_module; if missing, you’re not on a proper build—use the nginx.org repo (mainline).
  • Multiple server blocks on same IP:443 and reuseport conflicts
    • Use reuseport once for that IP:PORT (put it in one server; omit in others), or assign different IPs. (Common gotcha in multi-vhost setups.)
  • Handshake/0-RTT won’t enable
    • 0-RTT requires OpenSSL 3.5.1+ (or BoringSSL/QuicTLS/LibreSSL). Verify in nginx -V.
  • Still stuck?
    • Follow Nginx’s doc: enable debug logs and filter for messages with quic prefix; they show why a handshake fails.

Quick “golden” test script

# 1) confirm Nginx listening

      ss -u -lpn | grep :443 || echo "No UDP 443 listener!"
    
      ss -t -lpn | grep :8443 || echo "No TCP 8443 listener!"
    

# 2) local test (self-signed)

      curl -I --http3 -k https://127.0.0.1:8443/ || echo "curl may lack http3 support"
    

# 3) log check

      tail -n 20 /var/log/nginx/access.log
    

Extra pointers (when you go to prod)

  • Use a real hostname + certificate, enable HSTS, compression (brotli/zstd), and a sane cipher policy (kTLS if available).
  • Consider quic_host_key to avoid token invalidation on reloads.
  • Keep H2 enabled on TCP:443 for broad client compatibility while H3 adoption grows.

Summary

You installed an HTTP/3-capable Nginx, configured listen … quic on UDP:443 (with TCP fallback), advertised H3 via Alt-Svc, opened the firewall for UDP, and verified with CLI/browsers—plus you’ve got a robust troubleshooting checklist. The directives and behavior matched Nginx’s official QUIC/HTTP/3 docs.


https://nginx.org/index.html

https://www.wireshark.org/docs/relnotes

https://sanchitgurukul.com/basic-networking

https://sanchitgurukul.com/network-security

Disclaimer: This article may contain information that was accurate at the time of writing but could be outdated now. Please verify details with the latest vendor advisories or contact us at admin@sanchitgurukul.com.

Discover more from

Subscribe now to keep reading and get access to the full archive.

Continue reading