Build a QUIC/HTTP-3 Lab on Ubuntu — Step-by-Step with Python

Build a QUIC/HTTP-3 Lab on Ubuntu — Step-by-Step with Python
11/14/2025 •

Introduction

QUIC and HTTP/3 are no longer “experimental toys” — they are now powering the majority of modern web traffic (think YouTube, Gmail, Facebook, Cloudflare, etc.). QUIC replaces TCP + TLS with a single secure transport over UDP, bringing 0-RTT handshakes, stream multiplexing without head-of-line blocking, and built-in encryption. HTTP/3 rides on top of QUIC, inheriting these benefits for the web layer.

If you’re a network engineer, developer, or curious technologist, setting up your own QUIC/HTTP-3 lab is the best way to truly understand how it works. Instead of relying on browser flags or prebuilt demos, this article walks you through creating a from-scratch HTTP/3 environment on Ubuntu with Python (aioquic).

By the end, you’ll have:

  • A QUIC + HTTP/3 server in Python serving real files and routes.
  • A custom Python client to test GET, POST, and uploads.
  • TLS secrets for Wireshark decryption and optional QLOG traces for deep QUIC analysis.
  • Step-by-step commands, explanations, and troubleshooting pointers.

what you’ll get

  • A working QUIC + HTTP/3 stack in ~200 lines of Python.
  • Server: static files from /www, POST echo, /health, optional /upload streaming (easy to extend).
  • Client: single-request H3 client (GET/POST).
  • Observability: TLS secrets for Wireshark, optional QLOG traces for qvis.
  • Ops: systemd service, UFW, perf & hardening notes.
Diagram illustrating the topology of a QUIC testing lab, featuring a client device running Ubuntu, Windows, or macOS, connecting through a router to a server running Ubuntu 24.04, with software options including Caddy, NGINX, and aioquic on UDP port 443.

Prerequisites & lab layout

Why this matters: QUIC is UDP + TLS + HTTP/3 layered together; having a predictable environment avoids the usual “it compiles on my machine” hiccups.

Pointers

  • Use a Python virtual environment so your QUIC libs don’t collide with system packages.
  • Keep a simple directory layout so certs, logs, and content are obvious.

Commands

      sudo apt update
    
      sudo apt install -y python3-venv python3-pip build-essential openssl ufw
    
      python3 -m venv ~/h3lab
    

is used to create a Python virtual environment in the directory ~/h3lab.


🔎 What it does

  • python3 -m venv → runs the built-in venv module that ships with Python.
  • ~/h3lab → is the path to the folder where the virtual environment will be created.

When you run it:

  • A new directory ~/h3lab/ is created (if it doesn’t exist).
  • Inside it, you’ll see subfolders like bin/, lib/, and pyvenv.cfg.
  • These contain a private copy of the Python interpreter and its own site-packages directory (where pip installs packages).

Why it’s useful (in our QUIC/HTTP-3 lab)

  1. Isolation:
    • Packages installed here (e.g., aioquic) won’t interfere with your system Python or other projects.
    • You avoid version conflicts (important if you experiment with multiple Python libs).
  2. Reproducibility:
    • Anyone following the same steps gets the same clean environment.
    • If you break something, you can delete ~/h3lab and recreate it easily.
  3. Control:
    • You decide which Python version + packages are inside this lab.
    • Great for experiments like QUIC/HTTP-3, where dependencies evolve quickly.

🚀 Next step after creating it

You need to activate the environment:

      source ~/h3lab/bin/activate
    

After activation, your shell prompt changes (e.g., (h3lab) appears in front).
Now when you run:

      pip install --upgrade pip
    
      pip install aioquic
    

…it installs into ~/h3lab/lib/… only, not globally.

To validate it:

      pip show aioquic 
    

To deactivate, just run:

      deactivate
    

👉 So in short:

python3 -m venv ~/h3lab = make me a private Python playground called h3lab for my QUIC/HTTP-3 lab.


      mkdir -p ~/h3lab/{certs,www,logs}
    

🔎 What it does

So in one command, you’re making three directories inside ~/h3lab.
mkdir → the Linux command to create directories.
-p (parents) → Tells mkdir to create parent directories as needed. If ~/h3lab doesn’t exist, it will create it first, then the subfolders. Prevents errors if the parent path isn’t already there.
~/h3lab/{certs,www,logs} → uses brace expansion: It expands into three separate paths:
~/h3lab/certs
~/h3lab/www
~/h3lab/logs


Why it’s useful in our QUIC/HTTP-3 Lab

We’re organizing the project into clean folders:

  • certs/ → to hold TLS certificate (cert.pem) and private key (key.pem).
  • www/ → to hold web content (HTML files, test pages, uploads).
  • logs/ → to store logs like TLS secrets (secrets.log) and QLOG traces (quic.qlog.json).

This structure mirrors how a real web server environment would be laid out (certificates, web root, logs).


Example before & after

👉 Before running the command:

      ls ~/h3lab
    

👉 After running:

      ls -R ~/h3lab
    

🔑 In short:

This command creates a neat folder hierarchy for the lab in one shot, so you don’t have to make each directory manually.


Step 1 — Make a local TLS certificate (with SAN)

Why this matters: HTTP/3 always runs over TLS. Even for localhost, browsers/clients validate the hostname via the certificate’s Subject Alternative Name (SAN).

Commands

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

    

Quick test page

Pointers

  • For a real domain later, issue a proper cert (e.g., via ACME) and use that instead of self-signed.
  • You can add more SANs (e.g., your LAN IP) if you’ll access it from other machines.

Step 2 — The HTTP/3 server (no demo; clear structure)

Why this matters: Understanding events is key. QUIC emits transport events; H3Connection translates them into HTTP/3 events like HeadersReceived and DataReceived. You respond by sending headers/data and calling transmit().

Save as: ~/h3lab/h3_server.py

      #!/usr/bin/env python3
"""
Tiny but solid HTTP/3 server using aioquic.

Routes:
  GET  /           -> serves index.html from --www
  GET  /     -> serves static files from --www
  HEAD ...         -> metadata only
  POST /echo       -> echoes request body
  GET  /health     -> 200 OK (readiness probe)
  POST /upload     -> (optional) streams body to /www/uploads/.bin

Security:
  - blocks path traversal ("..")
  - sets basic content-type

Observability:
  - TLS secrets log for Wireshark
  - optional QLOG traces for qvis
"""
import argparse, asyncio, logging, mimetypes, uuid
from pathlib import Path

from aioquic.asyncio import serve
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.h3.connection import H3Connection, H3_ALPN
from aioquic.h3.events import HeadersReceived, DataReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import HandshakeCompleted, QuicEvent
from aioquic.quic.logger import QuicLogger

LOG = logging.getLogger("h3server")

def _b(s: str) -> bytes:
    return s.encode("utf-8")

class Http3Server(QuicConnectionProtocol):
    def __init__(self, *args, www: Path, **kwargs):
        super().__init__(*args, **kwargs)
        self._http: H3Connection | None = None
        self._www = www.resolve()
        self._buffers: dict[int, bytearray] = {}
        (self._www / "uploads").mkdir(parents=True, exist_ok=True)

    def quic_event_received(self, event: QuicEvent) -> None:
        if isinstance(event, HandshakeCompleted) and self._http is None:
            self._http = H3Connection(self._quic)
            LOG.info("Handshake completed; HTTP/3 ready")
        if self._http is None:
            return
        for http_event in self._http.handle_event(event):
            if isinstance(http_event, HeadersReceived):
                self._on_headers(http_event)
            elif isinstance(http_event, DataReceived):
                self._on_data(http_event)

    def _on_headers(self, ev: HeadersReceived) -> None:
        headers = {k.lower(): v for k, v in ev.headers}
        method = headers.get(b":method", b"GET").decode()
        raw_path = headers.get(b":path", b"/").decode()
        stream_id = ev.stream_id

        # Simple routing
        if method in ("GET", "HEAD") and raw_path == "/health":
            return self._reply(stream_id, 200, b"ok\n")

        if method == "POST" and raw_path == "/echo":
            self._buffers[stream_id] = bytearray()
            return

        if method == "POST" and raw_path == "/upload":
            # stream to disk; body handled in _on_data
            fn = f"{uuid.uuid4().hex}.bin"
            path = self._www / "uploads" / fn
            self._buffers[stream_id] = bytearray()
            # store the filename using a side key
            self._buffers[stream_id + 1] = bytearray(path.as_posix().encode())
            return

        if method in ("GET", "HEAD"):
            path = "/index.html" if raw_path == "/" else raw_path
            full = (self._www / path.lstrip("/")).resolve()
            # prevent ../../ escapes
            if not (full == self._www or self._www in full.parents):
                return self._reply(stream_id, 403, b"Forbidden\n")
            if full.is_file():
                data = full.read_bytes()
                ctype, _ = mimetypes.guess_type(full.name)
                resp_headers = [
                    (b":status", b"200"),
                    (b"server", b"py-h3"),
                    (b"content-length", _b(str(len(data)))),
                ]
                if ctype:
                    resp_headers.append((b"content-type", _b(ctype)))
                self._http.send_headers(stream_id, resp_headers)
                if method == "GET":
                    self._http.send_data(stream_id, data, end_stream=True)
                else:  # HEAD
                    self._http.send_data(stream_id, b"", end_stream=True)
                self.transmit()
            else:
                self._reply(stream_id, 404, b"Not Found\n")
        else:
            self._reply(stream_id, 405, b"Method Not Allowed\n")

    def _on_data(self, ev: DataReceived) -> None:
        buf = self._buffers.get(ev.stream_id)
        if buf is None:
            return
        buf.extend(ev.data)
        if ev.stream_ended:
            # echo route
            if ev.stream_id in self._buffers and (ev.stream_id + 1) not in self._buffers:
                body = bytes(self._buffers.pop(ev.stream_id))
                headers = [
                    (b":status", b"200"),
                    (b"server", b"py-h3"),
                    (b"content-length", _b(str(len(body)))),
                    (b"content-type", b"application/octet-stream"),
                ]
                self._http.send_headers(ev.stream_id, headers)
                self._http.send_data(ev.stream_id, body, end_stream=True)
                return self.transmit()
            # upload route (uses ev.stream_id+1 key to store filename)
            meta = self._buffers.pop(ev.stream_id + 1, None)
            if meta:
                out_path = Path(meta.decode())
                out_path.write_bytes(bytes(self._buffers.pop(ev.stream_id)))
                return self._reply(ev.stream_id, 200, _b(f"saved:{out_path.name}\n"))

    def _reply(self, stream_id: int, status: int, body: bytes) -> None:
        self._http.send_headers(stream_id, [
            (b":status", _b(str(status))),
            (b"server", b"py-h3"),
            (b"content-length", _b(str(len(body)))),
            (b"content-type", b"text/plain; charset=utf-8"),
        ])
        self._http.send_data(stream_id, body, end_stream=True)
        self.transmit()

async def main():
    ap = argparse.ArgumentParser(description="Tiny HTTP/3 server (QUIC)")
    ap.add_argument("--host", default="0.0.0.0")
    ap.add_argument("--port", type=int, default=4433)
    ap.add_argument("--cert", default=str(Path.home() / "h3lab/certs/cert.pem"))
    ap.add_argument("--key",  default=str(Path.home() / "h3lab/certs/key.pem"))
    ap.add_argument("--www",  default=str(Path.home() / "h3lab/www"))
    ap.add_argument("--secrets-log", default=str(Path.home() / "h3lab/logs/secrets.log"),
                    help="Write TLS secrets (Wireshark)")
    ap.add_argument("--qlog", default="", help="Directory to write QLOG traces")
    ap.add_argument("-v", "--verbose", action="store_true")
    args = ap.parse_args()

    logging.basicConfig(
        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
        level=logging.DEBUG if args.verbose else logging.INFO,
    )

    cfg = QuicConfiguration(
        is_client=False,
        alpn_protocols=H3_ALPN,
        secrets_log_file=open(args.secrets_log, "a", buffering=1),
    )
    cfg.load_cert_chain(args.cert, args.key)
    if args.qlog:
        cfg.quic_logger = QuicLogger()

    www_path = Path(args.www).resolve()
    www_path.mkdir(parents=True, exist_ok=True)

    # IMPORTANT: serve() is a coroutine; await it and manage lifecycle manually
    server = await serve(
        args.host, args.port,
        configuration=cfg,
        create_protocol=lambda *a, **kw: Http3Server(*a, www=www_path, **kw),
    )
    LOG.info("HTTP/3 server listening on udp://%s:%d  (www=%s)", args.host, args.port, www_path)
    LOG.info("TLS secrets log: %s", args.secrets_log)
    if args.qlog:
        LOG.info("QLOG enabled; will write JSON at shutdown to %s/*.qlog.json", args.qlog)

    try:
        await asyncio.Future()  # run forever
    finally:
        server.close()
        await server.wait_closed()
        if cfg.quic_logger and args.qlog:
            out = Path(args.qlog).resolve()
            out.mkdir(parents=True, exist_ok=True)
            (out / "quic.qlog.json").write_text(
                __import__("json").dumps(cfg.quic_logger.to_dict(), indent=2)
            )

if __name__ == "__main__":
    asyncio.run(main())

    

Pointers

  • QuicConfiguration(alpn_protocols=H3_ALPN) ensures HTTP/3 is negotiated.
  • We log TLS secrets to decrypt QUIC in Wireshark.
  • Directory traversal is blocked by checking full.parents.
  • /upload is intentionally simple: stream to memory then write to disk; for huge files, switch to chunked streaming to file.

Step 3 — The HTTP/3 client (simple and explicit)

Why this matters: A tiny client makes it easy to test different paths, headers, or bodies without relying on system curl builds.

Save as: ~/h3lab/h3_client.py

      #!/usr/bin/env python3
"""
Tiny HTTP/3 client for QUIC/aioquic.

Features:
  - GET/POST/HEAD with custom headers (-H)
  - Read POST body from --post "DATA" or --post-stdin (pipe/file)
  - Optional insecure mode (self-signed labs) or custom CA (--cacert)
  - Prints response headers to stderr and body to stdout (or --save file)
  - Simple timeout, verbose logs

Examples:
  python h3_client.py https://localhost:4433/ --insecure -v
  python h3_client.py https://localhost:4433/echo --post "hello" --insecure
  echo -n "tiny file" | python h3_client.py https://localhost:4433/upload --post-stdin --insecure
  python h3_client.py https://example.com/path -H "Accept: application/json"
"""

import argparse
import asyncio
import logging
import sys
from typing import List, Tuple
from urllib.parse import urlparse

from aioquic.asyncio import connect
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.h3.connection import H3Connection, H3_ALPN
from aioquic.h3.events import HeadersReceived, DataReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import HandshakeCompleted, QuicEvent

LOG = logging.getLogger("h3client")


def parse_header(h: str) -> Tuple[bytes, bytes]:
    if ":" not in h:
        raise argparse.ArgumentTypeError(f"Invalid header (missing ':'): {h}")
    k, v = h.split(":", 1)
    return k.strip().encode("utf-8"), v.lstrip().encode("utf-8")


class Http3Client(QuicConnectionProtocol):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._http: H3Connection | None = None
        self._events: List = []

    def quic_event_received(self, event: QuicEvent) -> None:
        if isinstance(event, HandshakeCompleted) and self._http is None:
            self._http = H3Connection(self._quic)
        if self._http is None:
            return
        for ev in self._http.handle_event(event):
            self._events.append(ev)

    async def request(
        self,
        authority: str,
        path: str,
        method: str = "GET",
        headers: List[Tuple[bytes, bytes]] | None = None,
        body: bytes = b"",
        save_path: str | None = None,
    ):
        assert self._http is not None
        stream_id = self._quic.get_next_available_stream_id(is_unidirectional=False)

        req_headers: List[Tuple[bytes, bytes]] = [
            (b":method", method.encode()),
            (b":scheme", b"https"),
            (b":authority", authority.encode()),
            (b":path", path.encode()),
            (b"user-agent", b"py-h3client"),
        ]
        if headers:
            req_headers.extend(headers)

        # End stream if HEAD and no body; otherwise, send body after headers
        end_stream = (method.upper() == "HEAD") and not body
        self._http.send_headers(stream_id, req_headers, end_stream=end_stream)
        if body and method.upper() != "HEAD":
            self._http.send_data(stream_id, body, end_stream=True)
        self.transmit()

        # Collect response
        got_fin = False
        out_f = None
        try:
            if save_path:
                out_f = open(save_path, "wb")

            while not got_fin:
                await asyncio.sleep(0.01)
                # iterate over a snapshot to allow removal
                for ev in list(self._events):
                    if isinstance(ev, HeadersReceived) and ev.stream_id == stream_id:
                        # print headers to stderr
                        for k, v in ev.headers:
                            sys.stderr.buffer.write(k + b": " + v + b"\n")
                        sys.stderr.buffer.write(b"\n")
                        sys.stderr.flush()
                        self._events.remove(ev)

                    elif isinstance(ev, DataReceived) and ev.stream_id == stream_id:
                        if out_f:
                            out_f.write(ev.data)
                        else:
                            sys.stdout.buffer.write(ev.data)
                            sys.stdout.flush()
                        got_fin = ev.stream_ended
                        self._events.remove(ev)
        finally:
            if out_f:
                out_f.flush()
                out_f.close()


async def main():
    ap = argparse.ArgumentParser(description="Tiny HTTP/3 client")
    ap.add_argument("url", help="https://host[:port]/path")
    ap.add_argument("-X", "--method", default=None, help="HTTP method (default: GET or POST if body)")
    ap.add_argument("--post", metavar="DATA", help="send POST with this DATA")
    ap.add_argument("--post-stdin", action="store_true", help="read POST body from stdin (pipe/file)")
    ap.add_argument("-H", "--header", action="append", type=parse_header, default=[],
                    help='Add header, e.g., -H "Accept: application/json"')
    ap.add_argument("--save", metavar="FILE", help="save response body to FILE instead of stdout")
    ap.add_argument("--timeout", type=float, default=10.0, help="overall operation timeout (seconds)")
    ap.add_argument("-v", "--verbose", action="store_true")
    ap.add_argument("--insecure", action="store_true", help="disable certificate verification")
    ap.add_argument("--cacert", help="custom CA bundle to verify server (overrides system store)")
    args = ap.parse_args()

    logging.basicConfig(
        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
        level=logging.DEBUG if args.verbose else logging.INFO,
    )

    u = urlparse(args.url)
    if u.scheme != "https":
        raise SystemExit("Only https:// URLs are supported for HTTP/3")
    host = u.hostname
    if host is None:
        raise SystemExit("Invalid URL: missing host")
    port = u.port or 4433  # default lab port
    path = u.path or "/"

    # Determine method/body
    body_bytes = b""
    if args.post is not None:
        body_bytes = args.post.encode()
    elif args.post_stdin:
        body_bytes = sys.stdin.buffer.read()

    method = args.method.upper() if args.method else ("POST" if (args.post or args.post_stdin) else "GET")

    # Build QUIC config
    cfg = QuicConfiguration(is_client=True, alpn_protocols=H3_ALPN)
    cfg.server_name = host  # SNI
    if args.insecure:
        import ssl
        cfg.verify_mode = ssl.CERT_NONE
    elif args.cacert:
        # Use custom CA bundle
        import ssl
        ctx = ssl.create_default_context(cafile=args.cacert)
        # aioquic expects verify via cfg.verify_mode + system store; to use custom bundle,
        # we switch to CERT_REQUIRED and let OpenSSL use the provided cafile path via env.
        cfg.verify_mode = ssl.CERT_REQUIRED
        # Workaround: set env var SSL_CERT_FILE for this process if needed.
        import os
        os.environ.setdefault("SSL_CERT_FILE", args.cacert)

    # Extra request headers from -H
    extra_headers: List[Tuple[bytes, bytes]] = list(args.header)

    async def runner():
        async with connect(host, port, configuration=cfg, create_protocol=Http3Client) as proto:
            client: Http3Client = proto  # type: ignore
            authority = f"{host}:{port}" if u.port else host
            await client.request(
                authority=authority,
                path=path or "/",
                method=method,
                headers=extra_headers,
                body=body_bytes,
                save_path=args.save,
            )

    try:
        await asyncio.wait_for(runner(), timeout=args.timeout)
    except asyncio.TimeoutError:
        LOG.error("Timed out after %.1f seconds", args.timeout)
        raise SystemExit(1)
    except Exception as e:
        # Surface common TLS/verification issues clearly
        LOG.error("Connection failed: %s", e)
        raise SystemExit(1)


if __name__ == "__main__":
    asyncio.run(main())

    

Pointers

  • authority must match the server’s SNI/cert host for verification (use –insecure for local self-signed).
  • This client prints headers on stderr and body on stdout—handy for piping.

Step 4 — Run, test, and see it work

Server

      sudo ufw allow 4433/udp
    
      python ~/h3lab/h3_server.py --host 0.0.0.0 --port 4433 --qlog ~/h3lab/logs -v
    

Client

# GET homepage

      python ~/h3lab/h3_client.py https://localhost:4433/ --insecure
    

# Health check

      python ~/h3lab/h3_client.py https://localhost:4433/health --insecure
    

# Echo

      python ~/h3lab/h3_client.py https://localhost:4433/echo --post "hello over QUIC" --insecure
    

# Check with Self sign certificate

      python ~/h3lab/h3_client.py https://localhost:4433/upload --post "tiny file" --insecure -v
    

# Upload something

      echo -n "tiny file" | python ~/h3lab/h3_client.py https://localhost:4433/upload --post-stdin --insecure -v

    
      ls -l ~/h3lab/www/uploads
    

Pointers

  • QUIC = UDP. If things time out, double-check you opened UDP in UFW / cloud SG.
  • Browsers: modern Chrome/Firefox support H3—load https://localhost:4433/ (allow the self-signed cert).

Step 5 — Observe & debug like a pro

Wireshark decryption (TLS secrets)

  1. Wireshark → Edit → Preferences → Protocols → TLS → set (Pre-Master) Key Log filename to ~/h3lab/logs/secrets.log.
  2. Start capture → run client → you’ll see QUIC decrypted packets, plus H3 headers/frames.

QLOG traces (qvis)

  • We enabled –qlog ~/h3lab/logs. Open quic.qlog.json in qvis to visualize handshake, streams, loss, PTO, congestion windows, etc.

Pointers

  • If you don’t see decrypted payloads, ensure the secrets file is being written (permissions, path).
  • QLOG is per run in this example; you can also rotate per connection.

Step 6 — Make it a service (systemd)

Why this matters: Realistic labs start & survive reboots the same way they would in prod.

      sudo tee /etc/systemd/system/h3server@.service >/dev/null <<'UNIT'
[Unit]
Description=Tiny QUIC/HTTP3 Server (Python/aioquic) for %i
After=network.target

[Service]
User=%i
WorkingDirectory=/root/h3lab
Environment="PATH=/root/h3lab/bin:/usr/bin"
ExecStart=/root/h3lab/bin/python /root/h3lab/h3_server.py \
  --host 0.0.0.0 --port 4433 \
  --www /root/h3lab/www \
  --secrets-log /root/h3lab/logs/secrets.log \
  --qlog /root/h3lab/logs
Restart=on-failure

[Install]
WantedBy=multi-user.target
UNIT
    
      sudo systemctl daemon-reload
    

# replace 'sanchit' with your username if needed

      sudo systemctl enable h3server@sanchit --now
    
      systemctl status h3server@sanchit
    

Pointers

  • Use the templated unit (%i) so each user can run their own instance.
  • Logs go to journalctl -u h3server@sanchit -f.

Step 7 — Hardening & performance tips

  • Dual-stack: also bind IPv6 (--host ::) and open UDP in firewall for v6.
  • UDP buffers (busy networks):
      echo 'net.core.rmem_max=2500000' | sudo tee /etc/sysctl.d/90-quic.conf
    
      echo 'net.core.wmem_max=2500000' | sudo tee -a /etc/sysctl.d/90-quic.conf
    
      sudo sysctl --system
    
  • MIME hygiene: add custom types if serving .wasm, .json, etc.
  • CORS / headers: if this fronts a web app, add route-specific headers (e.g., access-control-allow-origin).
  • Large uploads: swap the simple /upload buffer with chunked writes to a temp file to avoid memory spikes.
  • Real certs: for a domain like sanchitgurukul.xyz, use a proper certificate and remove --insecure from the client.

Step 8 — Common errors & fixes

  • “Nothing shows up”: QUIC uses UDP; open 4433/udp (not TCP).
  • Handshake but no H3: confirm both sides use H3_ALPN.
  • Cert verify failed: local tests → use --insecure on the client or match SNI to SAN.
  • Import issues: always run from a venv and pip install aioquic; don’t execute from inside the aioquic source tree.

Step 9 — Where to take this next

  • Add JSON APIs: extend routing to return/accept JSON; wire up request logging.
  • 0-RTT: cache session tickets so second connection sends data in 0-RTT.
  • QUIC datagrams: for telemetry or game-style updates without stream head-of-line blocking.
  • ASGI app: front FastAPI or Starlette via HTTP/3 (great for modern APIs).
  • Metrics: emit Prometheus counters for requests, bytes, rtt, loss (from QLOG or app).

Quick reference (commands you’ll use a lot)

# Start server (verbose + qlog)

      python ~/h3lab/h3_server.py --host 0.0.0.0 --port 4433 --qlog ~/h3lab/logs -v
    

# Test GET / HEAD

      python ~/h3lab/h3_client.py https://localhost:4433/ --insecure
    
      python ~/h3lab/h3_client.py https://localhost:4433/health --insecure
    

# Test POST echo

      python ~/h3lab/h3_client.py https://localhost:4433/echo --post "hello" --insecure
    

# Try a file you add under ~/h3lab/www

      cp /etc/hosts ~/h3lab/www/hosts.txt
    
      python ~/h3lab/h3_client.py https://localhost:4433/hosts.txt --insecure
    

Summary

In this article, we built a hands-on HTTP/3 lab on Ubuntu 24.04 using Python and aioquic. Step by step, you:

  1. Installed prerequisites and created a clean virtual environment.
  2. Generated TLS certificates (with SANs) — a must for HTTP/3.
  3. Built a Python QUIC/HTTP-3 server from scratch (with GET, HEAD, POST, health check, and uploads).
  4. Wrote a Python client to send and read HTTP/3 requests without relying on browser quirks or curl builds.
  5. Tested the lab with real requests, observed QUIC traffic in Wireshark, and visualized connections with QLOG/qvis.
  6. Deployed the server as a systemd service for persistence.
  7. Applied hardening tips for UDP buffers, firewall, MIME types, and production certs.
  8. Learned common troubleshooting tricks (UDP vs TCP ports, ALPN mismatch, cert errors).
  9. Saw how to extend the lab (0-RTT, datagrams, JSON APIs, FastAPI/ASGI integration).

The takeaway?

  • QUIC is real and widely deployed today.
  • HTTP/3 is easy to prototype locally — no need for massive server stacks.
  • With just Python and aioquic, you can explore modern transport protocols in depth.

This lab is a perfect foundation if you want to:

  • Study QUIC in Wireshark,
  • Prototype HTTP/3 APIs,
  • Test edge cases (loss, reordering, 0-RTT),
  • Or migrate your own services to QUIC/HTTP-3.

🚀 Next step: integrate this lab with your real domain (e.g., sanchitgurukul.xyz) using a trusted certificate, and you’ll be production-ready!


https://datatracker.ietf.org/doc/rfc9000

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