Today, we will go through the process of setting up a simple HTTP/3 server and verifying its functionality.

We will be using quic-go for for this purpose.

Generating Certificate

Since HTTP/3 requires all traffic to be encrypted, we’ll need a certificate. For our test, we can generate a self-signed TLS certificate for localhost. Go has a build-in tool, which you can run like this:

go run $(go env GOROOT)/src/crypto/tls/generate_cert.go --host localhost

$(go env GOROOT) will evaluate to the GOROOT path for whatever version of Go you are using.

Alternatively, you can use openssl to generate a certificate.

HTTP/3 server

The HTTP/3 handler uses the same http.Handler interface because, ultimately, it is still the HTTP protocol underneath. Later in the series, we will learn how to stream data from a client or a server.

You’ll need to provide an address to listen on and a handler for incoming requests:

srv := &http3.Server{
	// listen on port 8080
	Addr: ":8080",
	Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello, World!\n"))
	}),
}

Provide the path to the generated certificate and private key so it can serve a TLS connection:

// path to generated cert and key
if err := srv.ListenAndServeTLS("cert.pem", "key.pem"); err != nil {
	panic(err)
}
Full code
// server.go
package main

import (
	"net/http"

	"github.com/quic-go/quic-go/http3"
)

func main() {
	srv := &http3.Server{
		// listen on port 8080
		Addr: "127.0.0.1:8080",
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte("Hello, World!\n"))
		}),
	}
	// path to generated cert and key
	if err := srv.ListenAndServeTLS("cert.pem", "key.pem"); err != nil {
		panic(err)
	}
}

Now, run the server:

go run server.go

Let’s test it with curl. You can use a pre-built curl with HTTP/3 support from https://github.com/stunnel/static-curl.

Run the following command in another terminal:

curl --verbose --insecure --http3-only https://localhost:8080

The --insecure option tells curl to skip verification of the server’s TLS certificate.

The output will look something like this:

*   Trying 127.0.0.1:8080...
* Server certificate:
*  subject: O=Acme Co
*  start date: May  2 11:52:13 2025 GMT
*  expire date: May  2 11:52:13 2026 GMT
*  issuer: O=Acme Co
*  SSL certificate verify result: self-signed certificate (18), continuing anyway.
*   Certificate level 0: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
* Connected to localhost (127.0.0.1) port 8080
* using HTTP/3
* [HTTP/3] [0] OPENED stream for https://localhost:8080/
* [HTTP/3] [0] [:method: GET]
* [HTTP/3] [0] [:scheme: https]
* [HTTP/3] [0] [:authority: localhost:8080]
* [HTTP/3] [0] [:path: /]
* [HTTP/3] [0] [user-agent: curl/8.12.1]
* [HTTP/3] [0] [accept: */*]
> GET / HTTP/3
> Host: localhost:8080
> User-Agent: curl/8.12.1
> Accept: */*
>
* Request completely sent off
< HTTP/3 200
< content-type: text/plain; charset=utf-8
< date: Fri, 02 May 2025 12:11:26 GMT
< content-length: 14
<
Hello, World!
* Connection #0 to host localhost left intact

Trusting Server’s Certificate

Instead of skipping the server’s verification with --insecure, we can provide the server’s certificate (since it is self-signed):

 curl --verbose --cacert cert.pem  --http3 https://localhost:8080/

Here, we use --cacert cert.pem to provide curl with the certificate needed to verify the peer.

This command will produce output similar to the following:

*  CAfile: cert.pem
*  CApath: /etc/ssl/certs
* Server certificate:
*  subject: O=Acme Co
*  start date: May  3 12:58:35 2025 GMT
*  expire date: May  3 12:58:35 2026 GMT
*  subjectAltName: host "localhost" matched cert's "localhost"
*  issuer: O=Acme Co
*  SSL certificate verify ok.
*   Certificate level 0: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption

As you can see in the output host "localhost" matched cert's "localhost", curl now successfully verifies and trusts the server’s certificate.

With a small change, we can correctly advertise support for HTTP/3 during the TLS handshake.

First, load the generated certificate and private key:

// path to generated cert and key
cert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
if err != nil {
	panic(err)
}

Then, pass the certificate to the server’s TLS configuration, along with advertising HTTP/3 support:

srv := &http3.Server{
	// listen on port 8080
	Addr: "127.0.0.1:8080",
	TLSConfig: &tls.Config{
		// pass certificate
		Certificates: []tls.Certificate{cert},
		// advertise HTTP/3 support
		NextProtos:   []string{http3.NextProtoH3},
	},
	Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello, World!\n")),
	}),
}
if err := srv.ListenAndServe(); err != nil {
	panic(err)
}
Full code
// server.go
package main

import (
	"crypto/tls"
	"net/http"

	"github.com/quic-go/quic-go/http3"
)

func main() {
	// pass here generated cert and key
	cert, err := tls.LoadX509KeyPair("cert.pem", "key.pem")
	if err != nil {
		panic(err)
	}
	srv := &http3.Server{
		// listen on port 8080
		Addr: ":8080",
		TLSConfig: &tls.Config{
			Certificates: []tls.Certificate{cert},
			NextProtos:   []string{http3.NextProtoH3},
		},
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Write([]byte("Hello, World!\n"))
		}),
	}
	if err := srv.ListenAndServe(); err != nil {
		panic(err)
	}
}

Now you have a working HTTP/3 server. Nice work! 👏

Recap

Today, we learned how to write a simple HTTP/3 server using a self-signed certificate, correctly advertise HTTP/3 support, and test the setup using curl.

Next Parts: