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.

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'
Output:
root@sanchit:~# nginx -V 2>&1 | grep -E 'http_v3|TLSv1.3|OpenSSL'
built with OpenSSL 3.0.13 30 Jan 2024
configure arguments: --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx --modules-path=/usr/lib/nginx/modules --conf-path=/etc/nginx/nginx.conf --error-log-path=/var/log/nginx/error.log --http-log-path=/var/log/nginx/access.log --pid-path=/run/nginx.pid --lock-path=/run/nginx.lock --http-client-body-temp-path=/var/cache/nginx/client_temp --http-proxy-temp-path=/var/cache/nginx/proxy_temp --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp --http-scgi-temp-path=/var/cache/nginx/scgi_temp --user=nginx --group=nginx --with-compat --with-file-aio --with-threads --with-http_addition_module --with-http_auth_request_module --with-http_dav_module --with-http_flv_module --with-http_gunzip_module --with-http_gzip_static_module --with-http_mp4_module --with-http_random_index_module --with-http_realip_module --with-http_secure_link_module --with-http_slice_module --with-http_ssl_module --with-http_stub_status_module --with-http_sub_module --with-http_v2_module --with-http_v3_module --with-mail --with-mail_ssl_module --with-stream --with-stream_realip_module --with-stream_ssl_module --with-stream_ssl_preread_module --with-cc-opt='-g -O2 -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer -ffile-prefix-map=/home/builder/debuild/nginx-1.29.1/debian/debuild-base/nginx-1.29.1=. -flto=auto -ffat-lto-objects -fstack-protector-strong -fstack-clash-protection -Wformat -Werror=format-security -fcf-protection -fdebug-prefix-map=/home/builder/debuild/nginx-1.29.1/debian/debuild-base/nginx-1.29.1=/usr/src/nginx-1.29.1-1~noble -fPIC' --with-ld-opt='-Wl,-Bsymbolic-functions -flto=auto -ffat-lto-objects -Wl,-z,relro -Wl,-z,now -Wl,--as-needed -pie'
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:
echo '<h1>🎉 Nginx over HTTP/3</h1>' | sudo tee /usr/share/nginx/html/index.html >/dev/null
sudo nginx -t
root@sanchit:~# sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
};
sudo systemctl reload nginx
× nginx.service - nginx - high performance web server
Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled)
Active: failed (Result: exit-code) since Tue 2025-08-26 05:59:12 UTC; 21s ago
Docs: https://nginx.org/en/docs/
Process: 10031 ExecStart=/usr/sbin/nginx -c ${CONFFILE} (code=exited, status=1/FAILURE)
CPU: 32ms
Aug 26 05:59:10 sanchit systemd[1]: Starting nginx.service - nginx - high performance web server...
Aug 26 05:59:10 sanchit nginx[10031]: nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)
Aug 26 05:59:10 sanchit nginx[10031]: nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)
Aug 26 05:59:11 sanchit nginx[10031]: nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)
Aug 26 05:59:11 sanchit nginx[10031]: nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)
Aug 26 05:59:12 sanchit nginx[10031]: nginx: [emerg] bind() to 0.0.0.0:80 failed (98: Address already in use)
Aug 26 05:59:12 sanchit nginx[10031]: nginx: [emerg] still could not bind()
Aug 26 05:59:12 sanchit systemd[1]: nginx.service: Control process exited, code=exited, status=1/FAILURE
Aug 26 05:59:12 sanchit systemd[1]: nginx.service: Failed with result 'exit-code'.
Aug 26 05:59:12 sanchit systemd[1]: Failed to start nginx.service - nginx - high performance web server.
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
root@sanchit:~# systemctl status nginx -l --no-pager
● nginx.service - nginx - high performance web server
Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled)
Active: active (running) since Tue 2025-08-26 06:00:48 UTC; 5min ago
Docs: https://nginx.org/en/docs/
Process: 10236 ExecStart=/usr/sbin/nginx -c ${CONFFILE} (code=exited, status=0/SUCCESS)
Main PID: 10237 (nginx)
Tasks: 3 (limit: 4605)
Memory: 3.2M (peak: 3.5M)
CPU: 56ms
CGroup: /system.slice/nginx.service
├─10237 "nginx: master process /usr/sbin/nginx -c /etc/nginx/nginx.conf"
├─10238 "nginx: worker process"
└─10239 "nginx: worker process"
Aug 26 06:00:48 sanchit systemd[1]: Starting nginx.service - nginx - high performance web server...
Aug 26 06:00:48 sanchit systemd[1]: Started nginx.service - nginx - high performance web server.
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'
root@sanchit:~# ss -t -lpn | grep ':8443'
ss -u -lpn | grep ':8443'
LISTEN 0 511 0.0.0.0:8443 0.0.0.0:* users:(("nginx",pid=10239,fd=9),("nginx",pid=10238,fd=9),("nginx",pid=10237,fd=9))
LISTEN 0 511 [::]:8443 [::]:* users:(("nginx",pid=10239,fd=10),("nginx",pid=10238,fd=10),("nginx",pid=10237,fd=10))
UNCONN 0 0 0.0.0.0:8443 0.0.0.0:* users:(("nginx",pid=10239,fd=11),("nginx",pid=10238,fd=11),("nginx",pid=10237,fd=11))
UNCONN 0 0 0.0.0.0:8443 0.0.0.0:* users:(("nginx",pid=10239,fd=7),("nginx",pid=10238,fd=7),("nginx",pid=10237,fd=7))
UNCONN 0 0 [::]:8443 [::]:* users:(("nginx",pid=10239,fd=8),("nginx",pid=10238,fd=8),("nginx",pid=10237,fd=8))
UNCONN 0 0 [::]:8443 [::]:* users:(("nginx",pid=10239,fd=12),("nginx",pid=10238,fd=12),("nginx",pid=10237,fd=12))
5) First tests (CLI + browser)
A) Quick CLI check (curl)
If your curl supports HTTP/3:
curl -I --http2 -k https://localhost:8443/
root@sanchit:~# curl -I --http2 -k https://localhost:8443/
HTTP/2 200
server: nginx/1.29.1
date: Tue, 26 Aug 2025 06:10:49 GMT
content-type: text/html
content-length: 32
last-modified: Tue, 26 Aug 2025 05:58:29 GMT
etag: "68ad4d05-20"
alt-svc: h3=":8443"; ma=86400
accept-ranges: bytes
if curl has h3:
curl -I --http3 -k https://localhost:8443/
root@sanchit:~# curl -I --http3 -k https://localhost:8443/
curl: option --http3: the installed libcurl version doesn't support this
curl: try 'curl --help' or 'curl --manual' for more information
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)
- Visit
https://localhost:8443/and accept the self-signed cert. - Reload once more (Alt-Svc lets the browser upgrade).
- 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
log_formatmust go insidehttp {}, not insideserverorlocation.access_logcan use that format anywhere belowhttp {}.- 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.
- If you put it inside
- The variable
$http3is available only if Nginx is built with QUIC/HTTP3 support (like--with-http_v3_moduleand 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!"
root@sanchit:~# ss -u -lpn | grep :443 || echo "No UDP 443 listener!"
UNCONN 0 0 0.0.0.0:4433 0.0.0.0:* users:(("python",pid=7419,fd=7))
ss -t -lpn | grep :8443 || echo "No TCP 8443 listener!"
root@sanchit:~# ss -t -lpn | grep :8443 || echo "No TCP 8443 listener!"
LISTEN 0 511 0.0.0.0:8443 0.0.0.0:* users:(("nginx",pid=10239,fd=9),("nginx",pid=10238,fd=9),("nginx",pid=10237,fd=9))
LISTEN 0 511 [::]:8443 [::]:* users:(("nginx",pid=10239,fd=10),("nginx",pid=10238,fd=10),("nginx",pid=10237,fd=10))
# 2) local test (self-signed)
curl -I --http3 -k https://127.0.0.1:8443/ || echo "curl may lack http3 support"
root@sanchit:~# curl -I --http3 -k https://127.0.0.1:8443/ || echo "curl may lack http3 support"
curl: option --http3: the installed libcurl version doesn't support this
curl: try 'curl --help' or 'curl --manual' for more information
curl may lack http3 support
# 3) log check
tail -n 20 /var/log/nginx/access.log
root@sanchit:~# tail -n 20 /var/log/nginx/access.log
::1 - - [26/Aug/2025:06:01:07 +0000] "HEAD / HTTP/2.0" 200 0 "-" "curl/8.5.0" "-"
::1 - - [26/Aug/2025:06:10:49 +0000] "HEAD / HTTP/2.0" 200 0 "-" "curl/8.5.0" "-"
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.
Useful Links
https://www.wireshark.org/docs/relnotes
https://sanchitgurukul.com/basic-networking
https://sanchitgurukul.com/network-security
