TLS for a Go Server With Autocert and Lets Encrypt
Serve HTTPS from a Go program with autocert and Lets Encrypt, limit which hostnames may get certificates, persist the cache and handle port 80.

Getting a certificate used to be a purchase, a CSR and a calendar reminder. Now a Go program can ask Let's Encrypt for one while the first visitor waits, and renew it a month before it expires without anyone touching it. The package that does this, autocert, has been in golang.org/x/crypto for years and is about thirty lines to wire up. The part that deserves your attention is not the happy path but the three settings that decide whether it stays safe: which hostnames may get a certificate, where the certificates are stored, and which ports must be open.
Tested with Go 1.25.6 and golang.org/x/crypto v0.40.0, macOS on Apple Silicon (arm64)
This article sets up a server that way and tests everything that can be tested without a public domain. It belongs to the web cluster that ends in a production HTTP server, and it pairs with HTTP/2 and HTTP/3 and server timeouts. It ran on Go 1.25.6 with golang.org/x/crypto v0.40.0.
What autocert does
autocert.Manager implements the ACME protocol, the one Let's Encrypt speaks, on demand. When a TLS client connects and sends a server name, the manager's GetCertificate looks in its cache. If it has a valid certificate for that name, it returns it. If not, it contacts the CA, proves control of the name by answering a challenge, receives the certificate, stores it and returns it, while the client's handshake waits. Renewal happens in the background when a certificate is within 30 days of expiry.
The proof of control is a challenge. autocert uses TLS-ALPN-01: the CA connects to port 443 of the name and the manager answers with a special certificate on a special protocol (acme-tls/1). It needs no change to your handlers. The consequences are practical: the server must be reachable from the internet on 443 under that exact name, so none of this works on localhost, behind a firewall that blocks inbound traffic, or in a network where the name does not resolve to you. The protocol is specified in RFC 8555, and Let's Encrypt's rate limits are why the cache and the policy below matter.
The minimum setup
package main
import (
"crypto/tls"
"log"
"net/http"
"time"
"golang.org/x/crypto/acme/autocert"
)
func main() {
m := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist("example.com", "www.example.com"),
Cache: autocert.DirCache("/var/lib/myapp/certs"),
Email: "[email protected]", // where Lets Encrypt sends expiry warnings
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello over " + r.TLS.NegotiatedProtocol + "\n"))
})
srv := &http.Server{
Addr: ":443",
Handler: mux,
TLSConfig: m.TLSConfig(),
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 2 * time.Minute,
}
tlsConfig := srv.TLSConfig
tlsConfig.MinVersion = tls.VersionTLS12
// Port 80: answer ACME HTTP challenges if any arrive, and send everyone else to https.
go func() {
log.Fatal(http.ListenAndServe(":80", m.HTTPHandler(nil)))
}()
log.Fatal(srv.ListenAndServeTLS("", "")) // certificates come from the manager, so no file names
}That file is a sketch you deploy rather than run on a laptop, and I have not issued a real certificate from this machine for the reasons above. Every field in it matters, so here is what each one does.
Prompt: autocert.AcceptTOS records that you accept the CA's terms of service. Without it the manager refuses to register. Cache is the persistent store; DirCache writes files, and the directory should be owned by the service user with mode 0700, because it holds private keys. Email is optional, and worth setting because expiry notices are the only warning you get if renewal ever fails. The HTTP-01 handler on port 80 answers challenge requests when the CA uses them and redirects every other request to https. Passing nil as the fallback produces that redirect for you.
ListenAndServeTLS("", "") is how you tell the server that certificates come from TLSConfig.GetCertificate. And since TLSConfig() includes h2, you get HTTP/2 for free, as described in the previous article.
The host policy is a security setting
Here is the part that bites. A TLS client can put any name in its handshake. If your manager has no HostPolicy, a stranger who connects with ServerName: "anything.example.net" makes your server try to obtain a certificate for that name. It will fail, because you do not control the name, but each failure counts against your Let's Encrypt rate limits (there are limits on failed validations per account per hour) and fills your logs. An attacker with a script can use up your allowance and get your real renewals refused. The standard library gives you autocert.HostWhitelist for a fixed list, and a function for dynamic cases (a database of customers' custom domains, say):
HostPolicy: func(ctx context.Context, host string) error {
if !customerDomainExists(ctx, host) {
return fmt.Errorf("host %q is not registered", host)
}
return nil
},Keep that function fast and cache its answer. It runs inside the TLS handshake of a stranger's connection, so a slow database query there is a denial of service you wrote yourself.
You can test the policy, and the redirect, without any network access. The program below builds a manager with a whitelist and asks it for certificates directly.
package main
import (
"crypto/tls"
"fmt"
"net/http"
"net/http/httptest"
"golang.org/x/crypto/acme/autocert"
)
func main() {
m := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist("example.com"),
Cache: autocert.DirCache(".certs-demo"),
}
// A name outside the whitelist is refused before any network call to the CA.
_, err := m.GetCertificate(&tls.ClientHelloInfo{ServerName: "stranger.example.net"})
fmt.Println("stranger.example.net:", err)
// A handshake with no server name cannot be served at all.
_, err = m.GetCertificate(&tls.ClientHelloInfo{ServerName: ""})
fmt.Println("no server name:", err)
cfg := m.TLSConfig()
fmt.Println("ALPN protocols:", cfg.NextProtos)
// Port 80 handler: ordinary requests are redirected to https.
h := m.HTTPHandler(nil)
for _, req := range []*http.Request{
httptest.NewRequest("GET", "http://example.com/docs?page=2", nil),
httptest.NewRequest("POST", "http://example.com/form", nil),
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Printf("%s %s -> %d %s\n", req.Method, req.URL, rec.Code, rec.Header().Get("Location"))
}
}stranger.example.net: acme/autocert: host "stranger.example.net" not configured in HostWhitelist
no server name: acme/autocert: missing server name
ALPN protocols: [h2 http/1.1 acme-tls/1]
GET http://example.com/docs?page=2 -> 302 https://example.com/docs?page=2
POST http://example.com/form -> 400The whitelist rejects a stranger's name immediately, and the error names the host, which is what you want to see in a log. An empty server name (an IP address in the URL, or an ancient client) gets an error too, since there is nothing to issue a certificate for. NextProtos shows h2, http/1.1 and acme-tls/1, the challenge protocol. On port 80, a GET is redirected with a 302 and a POST is answered with a 400 rather than redirected, because a redirect would silently turn a form submission into a GET. If you want a permanent redirect for search engines, write the handler yourself and wrap only the challenge path with the manager.
Storage, restarts and several instances
The cache is where a reasonable setup goes wrong. On a container with an ephemeral disk, DirCache is empty after every deploy, and the first request for each name triggers a new order. Let's Encrypt allows only a handful of duplicate certificates per week for the same names, so a service redeployed ten times in a day can lock itself out. Mount a volume, or implement the small autocert.Cache interface (Get, Put, Delete) on top of a database or object store.
That interface is also how you run several instances. Two instances with separate directories each request their own certificates and answer each other's challenges unpredictably. With a shared cache, one instance can issue and the others read the result, but renewal races still exist, so many teams give up on autocert at that point and terminate TLS at the load balancer or with Caddy, which has solved clustered issuance properly. That is the honest boundary of this approach. A single Go process on a single host, such as an internal tool or a small product, is the sweet spot.
Testing and operating it
Staging first. Let's Encrypt has a staging environment with much higher limits and untrusted certificates. Point the manager at it through its Client field (&acme.Client{DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory"}) for your first deployment, confirm issuance works, then switch.
Keep an eye on expiry anyway. Renewal depends on the server being reachable and running. Add an external check that alerts when the certificate has less than 14 days left, or use the SSL certificate decoder by hand when something looks wrong.
Timeouts still apply. The first request for a new name waits for issuance, which takes a few seconds. A client with a short timeout may give up and retry, and the retry usually succeeds because the certificate is by then cached.
Limit what is exposed. Run the process as a non-root user with CAP_NET_BIND_SERVICE (or on high ports behind a redirect) so a bug in a handler is not a bug in a root process.


