Frontend Labs
⋯

Day 54 — TLS Certificate Rotation

Learning Objectives

  • Hot-reload TLS certificates without dropping connections
  • Use sync/atomic for 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).


Generated from week8.md · 8 Oct 2026 16:36