Right, so you’ve built your server, and it’s happily chatting away on port 80. That’s great, if you’re living in 1995. For the rest of us, we need to wrap this whole conversation in the secure, encrypted envelope of TLS. And because you’re not a multi-billion dollar corporation with a dedicated PKI team, you’re going to use Let’s Encrypt. It’s the only sane choice. It’s free, it’s automated, and it just works. The Go team, in their infinite wisdom, didn’t put the full ACME client (the protocol Let’s Encrypt uses) in the standard library, but they did bless an official one: golang.org/x/crypto/acme/autocert. This package is so good it feels like magic, and I’m inherently suspicious of magic. Let’s demystify it.

The Autocert Manager: Your New Best Friend

The autocert.Manager is the star of the show. It handles three huge jobs for you automatically: 1) it gets certificates from Let’s Encrypt, 2) it renews them before they expire, and 3) it manages them in a cache (usually just a directory on disk). Its most basic form requires just two things: a list of hostnames you’re responsible for and a place to store those certs so you don’t hit rate limits by asking for them on every restart.

package main

import (
	"crypto/tls"
	"fmt"
	"net/http"
	"golang.org/x/crypto/acme/autocert"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello, secure world!")
	})

	certManager := &autocert.Manager{
		Prompt:     autocert.AcceptTOS, // You agree to the Let's Encrypt TOS
		HostPolicy: autocert.HostWhitelist("yourdomain.com", "www.yourdomain.com"), // MUST change this
		Cache:      autocert.DirCache("/path/to/cert/cache"), // MUST change this; e.g., "./certs"
	}

	server := &http.Server{
		Addr:    ":443", // Standard port for HTTPS
		Handler: mux,
		TLSConfig: &tls.Config{
			GetCertificate: certManager.GetCertificate, // This is the crucial hook!
		},
	}

	// Start the HTTPS server
	go server.ListenAndServeTLS("", "") // Yes, the empty strings are correct. The Manager provides the certs.

	// You MUST also start a server on port 80 to handle the ACME HTTP challenge.
	// This redirects all HTTP traffic to HTTPS.
	http.ListenAndServe(":80", certManager.HTTPHandler(nil))
}

The beauty here is in the TLSConfig.GetCertificate callback. When a client connects, the server calls this function to get the right certificate for the requested hostname. The Manager uses this hook to check its cache. If it doesn’t have a valid cert, or if one needs renewing, it will go out and get one from Let’s Encrypt on the fly. It’s a bit of a party trick.

The ACME Challenge and Port 80 Non-Negotiable

Here’s the part everyone messes up. Let’s Encrypt has to prove you own yourdomain.com. The most straightforward way it does this (the HTTP-01 challenge) is by sending a request to http://yourdomain.com/.well-known/acme-challenge/<token>. Your server must be able to respond to that request on port 80. There is no way around it. This is why the example code includes the http.ListenAndServe(":80", ...) line.

The certManager.HTTPHandler(nil) provides a special handler that only responds to these ACME challenge requests correctly. For any other request, it will redirect them to HTTPS (which is usually what you want). If you pass it your own mux (e.g., certManager.HTTPHandler(mux)), it will handle challenges first and then pass other requests to your mux, which is useful if you need to serve something specific over HTTP.

Production-Grade Hardening

The basic setup works, but you’re better than basic. Let’s make it robust.

package main

import (
	"crypto/tls"
	"fmt"
	"log"
	"net/http"
	"time"

	"golang.org/x/crypto/acme/autocert"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello, production-grade secure world!")
	})

	certManager := &autocert.Manager{
		Prompt: autocert.AcceptTOS,
		HostPolicy: autocert.HostWhitelist(
			"yourdomain.com",
			"www.yourdomain.com",
			"api.yourdomain.com", // don't forget your subdomains!
		),
		Cache: autocert.DirCache("./certs"), // Use a relative or absolute path
		// Optional: Set a custom email for urgent renewal notices.
		// Email: "admin@yourdomain.com",
	}

	// Configure a modern, secure TLS configuration.
	// This is where you get an A+ on SSL Labs.
	tlsConfig := &tls.Config{
		GetCertificate: certManager.GetCertificate,
		MinVersion:     tls.VersionTLS12, // Enforce TLS 1.2 as a minimum
		CurvePreferences: []tls.CurveID{
			tls.X25519, // Modern, fast elliptic curves first
			tls.CurveP256,
		},
		CipherSuites: []uint16{
			// A selection of modern, strong cipher suites.
			tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
			tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
			tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
			tls.TLS_ECDHE_RSA_WITH_CHACHA20_PYLY1305,
			tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
			tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
		},
	}

	// Create our main server
	server := &http.Server{
		Addr:         ":443",
		Handler:      mux,
		TLSConfig:    tlsConfig,
		ReadTimeout:  10 * time.Second,    // Timeouts are critical for security
		WriteTimeout: 10 * time.Second,
		IdleTimeout:  120 * time.Second,
	}

	// Start the HTTPS server
	go func() {
		log.Printf("Starting HTTPS server on :443")
		err := server.ListenAndServeTLS("", "")
		if err != nil {
			log.Fatalf("HTTPS server failed: %v", err)
		}
	}()

	// Start the HTTP server for challenges and redirects
	log.Printf("Starting HTTP server on :80")
	err := http.ListenAndServe(":80", certManager.HTTPHandler(nil))
	if err != nil {
		log.Fatalf("HTTP server failed: %v", err)
	}
}

The Staging Environment Trap

Let’s Encrypt has strict rate limits. If you mess up your code and spam their production API, they will temporarily block you. It’s a rite of passage. To avoid this, always test with their staging environment first.

// For testing ONLY. Remove for production.
certManager := &autocert.Manager{
    Prompt:     autocert.AcceptTOS,
    HostPolicy: autocert.HostWhitelist("test.yourdomain.com"),
    Cache:      autocert.DirCache("./staging-certs"), // Separate cache!
    Client: &acme.Client{
        DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory", // STAGING URL
    },
}

Use a fake domain you control (like test.yourdomain.com) pointed at your test server. Once it works flawlessly, switch the DirectoryURL back to the default (the production endpoint) and change your hostname. This will save you from many frustrated nights. Trust me.