Day 54 — TLS Certificate Rotation
Learning Objectives
- Hot-reload TLS certificates without dropping connections
- Use
sync/atomicfor lock-free cert swapping - Monitor cert expiry as part of server health
Exercise: Zero-Downtime Cert Rotation
package main
import (
"crypto/tls"
"fmt"
"os"
"sync/atomic"
"time"
"unsafe"
)
// CertReloader watches for cert file changes and atomically swaps certificates
type CertReloader struct {
certPath string
keyPath string
certPointer unsafe.Pointer // *tls.Certificate — atomic swap
lastModTime time.Time
}
func NewCertReloader(certPath, keyPath string) (*CertReloader, error) {
r := &CertReloader{certPath: certPath, keyPath: keyPath}
if err := r.reload(); err != nil {
return nil, err
}
return r, nil
}
func (r *CertReloader) reload() error {
cert, err := tls.LoadX509KeyPair(r.certPath, r.keyPath)
if err != nil {
return fmt.Errorf("reload cert: %w", err)
}
// Parse to get expiry for logging
x509Cert, err := x509.ParseCertificate(cert.Certificate[0])
if err == nil {
daysLeft := int(time.Until(x509Cert.NotAfter).Hours() / 24)
fmt.Printf("[CERT] loaded %s (expires in %d days)\n", x509Cert.Subject.CommonName, daysLeft)
}
// Atomic swap — zero downtime (no lock needed; pointer write is atomic on 64-bit)
atomic.StorePointer(&r.certPointer, unsafe.Pointer(&cert))
return nil
}
// GetCertificate is called by tls.Config for each TLS handshake
func (r *CertReloader) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
ptr := atomic.LoadPointer(&r.certPointer)
cert := (*tls.Certificate)(ptr)
return cert, nil
}
// Watch polls for cert file changes and hot-reloads
func (r *CertReloader) Watch(stop <-chan struct{}) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
info, err := os.Stat(r.certPath)
if err != nil { continue }
if info.ModTime().After(r.lastModTime) {
r.lastModTime = info.ModTime()
if err := r.reload(); err != nil {
fmt.Printf("[CERT] reload error: %v\n", err)
} else {
fmt.Println("[CERT] reloaded successfully (no connections dropped)")
}
}
}
}
}
func main() {
reloader, err := NewCertReloader("server.crt", "server.key")
if err != nil {
fmt.Println("ERROR:", err)
return
}
stop := make(chan struct{})
go reloader.Watch(stop)
tlsConfig := &tls.Config{
GetCertificate: reloader.GetCertificate, // called per-handshake
MinVersion: tls.VersionTLS12,
}
srv := &tls.Listener{} // attach tlsConfig
_ = srv
fmt.Println("TLS server with hot-reload on :8443")
fmt.Println("Replace server.crt and server.key — reloads within 30s without restart")
// In production: attach to a real server
time.Sleep(24 * time.Hour)
close(stop)
}
Real-World Context
Let's Encrypt certificates expire every 90 days. Certbot and cert-manager automate renewal, but your server must reload without downtime. GetCertificate is the hook that Go's TLS implementation calls for each new handshake — new connections get the new cert while existing connections keep the old cert (they've already completed the handshake).