# Part 14: TLS: encrypting the wire, hot-reloading certificates, and verifying the database

*Every password and token in this series still crosses the network as plain text. We add HTTPS, learn what a certificate actually proves, rotate certificates without a restart, and encrypt (and verify) the connection to Postgres.*

---

**Series:** Build a production-grade REST API in Go · **Part 14 of 17** **Where we are:** the API from Part 13, still speaking plain HTTP.

Look at what we built in Parts 11-13: password hashing, signed tokens, rotation, rate limiting. All of it is undone if someone on the network path can *read* the traffic. A Wi-Fi hotspot, a compromised router or a proxy sees `POST /auth/login {"password":"..."}` and every `Authorization: Bearer ...` header in the clear. **Tokens are bearer credentials: whoever holds one can use it.**

TLS (the "S" in HTTPS) gives three guarantees:

1. **Confidentiality**: nobody on the path can read the traffic.
2. **Integrity**: nobody can modify it undetected.
3. **Authenticity**: you're really talking to the server you intended, not an impostor.

The third is the one people forget, and it's where certificates come in.

## How trust works (the 60-second version)

1. The server holds a **private key** (never leaves the server) and a **certificate**: a document saying *"this public key belongs to* `api.example.com`*"*, **signed by a Certificate Authority (CA)**.
2. Your operating system / browser / `curl` ships with a list of CAs it trusts. When it connects, it checks: does the signature chain lead to a trusted CA? Is the certificate unexpired? **Is the hostname I dialed listed in the certificate's Subject Alternative Names (SAN)?**
3. Then both sides derive temporary session keys (ephemeral Diffie-Hellman) and encrypt. Because those keys are ephemeral, stealing the server's private key *later* can't decrypt traffic recorded earlier (**forward secrecy**).

Step 2 is the point. A man-in-the-middle can encrypt traffic just fine; what they cannot do is present a certificate for your hostname that a trusted CA signed.

> **Never "fix" a TLS error with** `--insecure` **or** `InsecureSkipVerify: true`**.** It keeps the encryption but throws away step 2, so an attacker in the middle is accepted. It is the most common way TLS gets silently disabled in production code.

## Step 1: certificates for development

We need a certificate to test with. A real one comes from a CA like Let's Encrypt; for development we make our own **local CA** and sign a server certificate with it. Why a CA rather than a single self-signed cert? Clients then trust *one file* (`ca.pem`), and verification works exactly as in production (chain *and* hostname checks), so nobody is tempted to disable it. It also lets us re-issue the server certificate (rotation) without clients changing what they trust.

`internal/devcert/devcert.go (excerpt)`

```go
// NewCA creates a CA valid for one year.
func NewCA(now time.Time) (*CA, error) {
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, err
	}
	tmpl := &x509.Certificate{
		SerialNumber:          serial(),
		Subject:               pkix.Name{CommonName: "rest-go dev CA", Organization: []string{"rest-go (development only)"}},
		NotBefore:             now.Add(-time.Minute),
		NotAfter:              now.Add(365 * 24 * time.Hour),
		IsCA:                  true,
		BasicConstraintsValid: true,
		MaxPathLen:            0, // may sign leaf certs, not further CAs
		MaxPathLenZero:        true,
		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
	}
	der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
	if err != nil {
		return nil, err
	}
	cert, err := x509.ParseCertificate(der)
	if err != nil {
		return nil, err
	}
	keyDER, err := x509.MarshalPKCS8PrivateKey(key)
	if err != nil {
		return nil, err
	}
	return &CA{
		cert: cert, key: key,
		CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
		KeyPEM:  pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}),
	}, nil
}

// IssueServer signs a server certificate valid for `validity` whose Subject
// Alternative Names are hosts (DNS names and/or IPs).
//
// SAN is what clients check. Since 2017 browsers and Go ignore the old "Common
// Name" field: a cert without a matching SAN fails with "certificate is not
// valid for any names" even if the CN matches.
func (ca *CA) IssueServer(hosts []string, now time.Time, validity time.Duration) (Server, error) {
	if len(hosts) == 0 {
		return Server{}, errors.New("at least one host required")
	}
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return Server{}, err
	}
	tmpl := &x509.Certificate{
		SerialNumber: serial(),
		Subject:      pkix.Name{CommonName: hosts[0]},
		NotBefore:    now.Add(-time.Minute),
		NotAfter:     now.Add(validity),
		KeyUsage:     x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}
	for _, h := range hosts {
		if ip := net.ParseIP(h); ip != nil {
			tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
		} else {
			tmpl.DNSNames = append(tmpl.DNSNames, h)
		}
	}
	der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &key.PublicKey, ca.key)
	if err != nil {
		return Server{}, err
	}
	keyDER, err := x509.MarshalPKCS8PrivateKey(key)
	if err != nil {
		return Server{}, err
	}
	return Server{
		CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
		KeyPEM:  pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}),
	}, nil
}
```

Highlights:

- **ECDSA P-256 keys**: small, fast, modern.
- `IsCA` **+** `MaxPathLen: 0`: the CA can sign server certificates but not create further CAs.
- **SAN entries** (`DNSNames`, `IPAddresses`): Go and all modern browsers **ignore the old "Common Name"** field; a certificate without a matching SAN fails with *"certificate is not valid for any names"*. Our dev cert covers `localhost`, `127.0.0.1`, `::1`, `api`, and `postgres`.
- Validity: 90 days for the server, 1 year for the CA.

A tiny command wraps it (`make certs` → `certs/ca.pem`, `server.pem`, `server-key.pem`; the directory is git-ignored). This is **development only**: the key files are world-readable so containers can read them. In production the certificate comes from a real CA, the private key is readable only by the service account, and a CA key never sits on a server.

## Step 2: serve HTTPS, with sensible defaults

Go makes the server part easy, so the interesting part is the choices:

`cmd/rest/main.go (lines 268-279)`

```go
	// TLS: load the certificate now (fail fast) and keep it hot-reloadable.
	var tlsReloader *tlsutil.Reloader
	middlewares := []func(http.Handler) http.Handler{}
	if cfg.TLSEnabled() {
		if tlsReloader, err = tlsutil.NewReloader(cfg.TLSCertFile, cfg.TLSKeyFile, log); err != nil {
			return err
		}
		metrics.TrackCertExpiry(tlsReloader.Expiry)
		if cfg.HSTSMaxAge > 0 {
			middlewares = append(middlewares, middleware.HSTS(cfg.HSTSMaxAge))
		}
	}
```

`internal/tlsutil/tlsutil.go (excerpt)`

```go
// Config returns the server TLS settings.
//
// Deliberately minimal: Go's defaults are maintained by the Go team and are
// better than a hand-written cipher list (old lists age badly and have shipped
// weak suites in many products). We only set the floor:
//   - TLS 1.2 minimum: TLS 1.0/1.1 are deprecated (RFC 8996) and have known attacks.
//   - TLS 1.3 is used automatically with modern clients; pass min=tls.VersionTLS13
//     when ALL clients are known to support it.
func (r *Reloader) Config(minVersion uint16) *tls.Config {
	return &tls.Config{
		MinVersion:     minVersion,
		GetCertificate: r.GetCertificate,
		// HTTP/2 is negotiated via ALPN; net/http adds "h2" itself when serving TLS.
	}
}

// ParseMinVersion maps "1.2" / "1.3" to the crypto/tls constant.
func ParseMinVersion(s string) (uint16, error) {
	switch s {
	case "", "1.2":
		return tls.VersionTLS12, nil
	case "1.3":
		return tls.VersionTLS13, nil
	}
	return 0, fmt.Errorf("TLS_MIN_VERSION must be 1.2 or 1.3, got %q", s)
}
```

- `MinVersion: TLS 1.2`. TLS 1.0 and 1.1 are deprecated (RFC 8996) with known attacks. TLS 1.3 is negotiated automatically with any modern client. Set `TLS_MIN_VERSION=1.3` once you know all your clients support it.
- **We do not hand-pick cipher suites.** Go's defaults are maintained by the Go team and kept current; hand-written cipher lists age badly and have shipped weak suites in many products. The best configuration is the one you don't write.
- **HTTP/2 comes free**. When serving TLS, `net/http` advertises `h2` via ALPN; `curl -v` will show `using HTTP/2`.
- `ListenAndServeTLS("", "")` with empty file names: certificates come from `TLSConfig.GetCertificate`, which is what makes hot reloading possible.
- **No plain-HTTP listener and no http→https redirect.** For a website, redirecting is friendly. For an *API*, it's a trap: the client's first request already carried its `Authorization` header in clear text before any redirect could happen. An API should fail fast.
- **HSTS** (`Strict-Transport-Security`): tells browsers to use HTTPS only for this host for the next N seconds, defeating "SSL stripping" downgrade attacks. Sent only over TLS, with a default of 180 days. Roll it out with a short value first: a long max-age on a host that later loses HTTPS locks users out.

`internal/middleware/middleware.go (excerpt)`

```go
// HSTS tells browsers (and well-behaved clients) to use HTTPS only for this host
// for the next maxAge, even if someone types or links http://. It defeats
// "SSL stripping" downgrade attacks. Only send it over HTTPS: browsers ignore it
// on plain HTTP, and a long max-age on a host that later loses HTTPS locks
// users out. Start small when rolling out, and treat "preload" as irreversible.
func HSTS(maxAge time.Duration) func(http.Handler) http.Handler {
	value := "max-age=" + strconv.Itoa(int(maxAge.Seconds()))
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Header().Set("Strict-Transport-Security", value)
			next.ServeHTTP(w, r)
		})
	}
}
```

## Step 3: rotating certificates without downtime

Certificates expire (Let's Encrypt: every 90 days) and are renewed by an external process (certbot, cert-manager, your secrets platform) which rewrites the files. A server that read them once at startup keeps serving the old, soon-expired certificate until someone restarts it. That is a classic outage: *"it worked for 89 days."*

So the server re-reads the files when they change:

`internal/tlsutil/tlsutil.go (excerpt)`

```go
// Reloader serves a certificate that can be replaced WITHOUT restarting.
//
// Certificates expire (Let's Encrypt: 90 days) and are renewed by an external
// process (certbot, cert-manager, your secret store) that rewrites the files. A
// server that loads them once at startup keeps serving the old, soon-expired
// cert until someone restarts it: a classic outage. Here every handshake calls
// GetCertificate, which re-reads the files when their modification time changes.
type Reloader struct {
	certFile, keyFile string
	log               *slog.Logger
	interval          time.Duration
	now               func() time.Time

	mu        sync.RWMutex
	cert      *tls.Certificate
	leaf      *x509.Certificate
	certMod   time.Time
	keyMod    time.Time
	lastCheck time.Time
}

// NewReloader loads the pair immediately, so a missing or invalid cert fails at
// startup (loudly) rather than on the first client connection.
func NewReloader(certFile, keyFile string, log *slog.Logger) (*Reloader, error) {
	r := &Reloader{certFile: certFile, keyFile: keyFile, log: log, interval: 5 * time.Second, now: time.Now}
	if err := r.load(); err != nil {
		return nil, err
	}
	r.lastCheck = r.now()
	return r, nil
}

// GetCertificate is plugged into tls.Config; it runs on every handshake.
func (r *Reloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
	r.maybeReload()
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.cert, nil
}

func (r *Reloader) maybeReload() {
	r.mu.Lock()
	if r.now().Sub(r.lastCheck) < r.interval { // stat at most every few seconds, not per handshake
		r.mu.Unlock()
		return
	}
	r.lastCheck = r.now()
	cm, err1 := modTime(r.certFile)
	km, err2 := modTime(r.keyFile)
	changed := err1 == nil && err2 == nil && (!cm.Equal(r.certMod) || !km.Equal(r.keyMod))
	r.mu.Unlock()
	if !changed {
		return
	}
	// On failure KEEP SERVING THE OLD CERT. A half-written file (cert replaced,
	// key not yet) must not take the site down; the next check retries.
	if err := r.load(); err != nil {
		r.log.Error("tls certificate reload failed; keeping previous certificate", "err", err)
		return
	}
	r.log.Info("tls certificate reloaded", "expires", r.Expiry())
}
```

- `GetCertificate` runs on **every TLS handshake**. `maybeReload` stats the files at most every 5 seconds and reloads when the modification time changed. (`os.Stat` follows symlinks, so Kubernetes' symlink-swap secret updates work.)
- **A bad new file keeps the old certificate serving.** A half-written rotation (new cert, old key) must not take the site down; we log the error and retry on the next check. Both behaviors are unit-tested.
- `NewReloader` loads immediately, so a missing or mismatched certificate **fails at startup**, not on the first client.
- The expiry date is exported as a metric (`tls_certificate_expiry_timestamp_seconds`) with an alert at 14 days. **Don't trust renewal automation blindly: monitor the outcome.**

## Step 4: encrypt the database connection too

The API-to-Postgres link crosses a network as well. Postgres supports TLS; the important part is the client's `sslmode`:

| `sslmode` | Encrypted? | Server identity checked? | Stops a man-in-the-middle? |
| --- | --- | --- | --- |
| `disable` | no | no | no |
| `prefer` (the default!) | maybe: silently falls back to plain | no | no |
| `require` | yes | no | **no** |
| `verify-ca` | yes | CA only | partly |
| `verify-full` | yes | CA **and hostname** | **yes** |

`require` is the common false comfort: the traffic is encrypted, but to *whoever answers*. Only `verify-full` checks that the server is who it claims, the same step-2 logic as HTTPS. Our connection string becomes:

```plaintext
postgres://app:...@postgres:5432/app?sslmode=verify-full&sslrootcert=/certs/ca.pem
```

In compose, Postgres starts with TLS on and a 1.2 floor. Postgres insists the private key be owned by the `postgres` user with mode 0600, which a bind mount can't guarantee, so we copy the key into place first:

`docker-compose.yml (lines 17-34)`

```yaml
      - pgdata:/var/lib/postgresql/data      # named volume: data survives `down`
      - ./certs:/certs:ro                    # dev CA + server cert (`make certs`)
    # Postgres refuses a private key that is group/world readable or not owned by
    # the postgres user, which bind mounts can't guarantee, so copy it into place
    # first. Then start Postgres with TLS on and a 1.2 floor.
    command:
      - sh
      - -c
      - |
        cp /certs/server.pem /var/lib/postgresql/server.crt
        cp /certs/server-key.pem /var/lib/postgresql/server.key
        chown postgres:postgres /var/lib/postgresql/server.crt /var/lib/postgresql/server.key
        chmod 600 /var/lib/postgresql/server.key
        exec docker-entrypoint.sh postgres \
          -c ssl=on \
          -c ssl_cert_file=/var/lib/postgresql/server.crt \
          -c ssl_key_file=/var/lib/postgresql/server.key \
          -c ssl_min_protocol_version=TLSv1.2
```

## Tests: attack your own server

The tests start a real TLS listener with our production config and attack it:

`internal/tlsutil/tlsutil_test.go (excerpt)`

```go
// Start a real HTTPS server using the production TLS config and attack it.
func TestServerTLSBehaviour(t *testing.T) {
	dir := t.TempDir()
	ca := writeCerts(t, dir, "localhost", "127.0.0.1")
	r, err := tlsutil.NewReloader(filepath.Join(dir, "server.pem"), filepath.Join(dir, "server-key.pem"), quiet)
	if err != nil {
		t.Fatal(err)
	}
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	srv := &http.Server{
		Handler:   http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }),
		TLSConfig: r.Config(tls.VersionTLS12),
	}
	go srv.ServeTLS(ln, "", "") // certificates come from GetCertificate
	t.Cleanup(func() { srv.Close() })
	url := "https://" + ln.Addr().String()

	pool := x509.NewCertPool()
	pool.AppendCertsFromPEM(ca)
	client := func(cfg *tls.Config) *http.Client {
		return &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: cfg, ForceAttemptHTTP2: true}}
	}

	t.Run("trusted client connects over HTTP/2 with TLS 1.3", func(t *testing.T) {
		resp, err := client(&tls.Config{RootCAs: pool}).Get(url)
		if err != nil {
			t.Fatal(err)
		}
		resp.Body.Close()
		if resp.ProtoMajor != 2 || resp.TLS.Version != tls.VersionTLS13 {
			t.Fatalf("proto=%d tls=%x", resp.ProtoMajor, resp.TLS.Version)
		}
	})
	t.Run("TLS 1.1 clients are refused", func(t *testing.T) {
		_, err := client(&tls.Config{RootCAs: pool, MaxVersion: tls.VersionTLS11}).Get(url)
		if err == nil {
			t.Fatal("old protocol version was accepted")
		}
	})
	t.Run("client that does not trust our CA refuses the server", func(t *testing.T) {
		if _, err := client(&tls.Config{}).Get(url); err == nil {
			t.Fatal("unknown CA accepted")
		}
	})
	t.Run("hostname must match the certificate SAN", func(t *testing.T) {
		if _, err := client(&tls.Config{RootCAs: pool, ServerName: "evil.example.com"}).Get(url); err == nil {
			t.Fatal("wrong hostname accepted")
		}
	})
	t.Run("plain HTTP to the TLS port fails", func(t *testing.T) {
		resp, err := http.Get("http://" + ln.Addr().String())
		if err == nil {
			defer resp.Body.Close()
			if resp.StatusCode == 200 {
				t.Fatal("served plaintext")
			}
		}
	})
}
```

Each subtest is a security property: a trusted client gets HTTP/2 over TLS 1.3; a TLS 1.1 client is refused; a client without our CA refuses us; a wrong hostname is refused; plain HTTP to the TLS port is not served. Further tests cover the rotation and "keep the old certificate when the new files are broken" behaviors (using a polling loop rather than a fixed `sleep`, so tests are neither flaky nor slow).

## Complete code for this part

Everything to type, file by file: the certificate generator and its command, the TLS reloader, and the edits that switch the API and Postgres to TLS. Then `go mod tidy && go build ./...`.

**Change** `internal/config/config.go`

```diff
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -46,6 +46,13 @@ type Config struct {
 	RateLimitBurst int
 	TrustedProxies string // CIDRs whose X-Forwarded-For we believe, e.g. "10.0.0.0/8"
 
+	// TLS. Set both files to serve HTTPS; leave empty for plain HTTP (e.g. when a
+	// load balancer terminates TLS in front of us).
+	TLSCertFile string
+	TLSKeyFile  string
+	TLSMinVer   string        // "1.2" (default) or "1.3"
+	HSTSMaxAge  time.Duration // Strict-Transport-Security lifetime; 0 disables. Only sent over TLS.
+
 	// Per-user limit for authenticated requests (0 disables). Keyed by user id, so
 	// people sharing an IP (office, mobile carrier NAT) don't share a budget.
 	UserRateLimitRPS   float64
@@ -95,6 +102,10 @@ func Load() (Config, error) {
 		ServiceName:         env("SERVICE_NAME", "rest-go"),
 		OTLPEndpoint:        os.Getenv("OTLP_ENDPOINT"),
 		TraceSampleRatio:    1.0,
+		TLSCertFile:         os.Getenv("TLS_CERT_FILE"),
+		TLSKeyFile:          os.Getenv("TLS_KEY_FILE"),
+		TLSMinVer:           env("TLS_MIN_VERSION", "1.2"),
+		HSTSMaxAge:          180 * 24 * time.Hour,
 		UserRateLimitRPS:    30,
 		UserRateLimitBurst:  60,
 		AuthRateLimitPerMin: 10,
@@ -128,6 +139,7 @@ func Load() (Config, error) {
 		{"ACCESS_TOKEN_TTL", &c.AccessTokenTTL},
 		{"REFRESH_TOKEN_TTL", &c.RefreshTokenTTL},
 		{"LOGIN_FAILURE_REFILL", &c.LoginFailureRefill},
+		{"HSTS_MAX_AGE", &c.HSTSMaxAge},
 	}
 	for _, d := range durations {
 		if v := os.Getenv(d.key); v != "" {
@@ -209,6 +221,12 @@ func Load() (Config, error) {
 		return c, fmt.Errorf("REQUEST_TIMEOUT (%s) must be below WRITE_TIMEOUT (%s)", c.RequestTimeout, c.WriteTimeout)
 	case c.DefaultPageSize < 1 || c.MaxPageSize < c.DefaultPageSize:
 		return c, fmt.Errorf("need 1 <= DEFAULT_PAGE_SIZE <= MAX_PAGE_SIZE")
+	case (c.TLSCertFile == "") != (c.TLSKeyFile == ""):
+		return c, fmt.Errorf("set both TLS_CERT_FILE and TLS_KEY_FILE, or neither")
+	case c.TLSMinVer != "1.2" && c.TLSMinVer != "1.3":
+		return c, fmt.Errorf("TLS_MIN_VERSION must be 1.2 or 1.3")
+	case c.HSTSMaxAge < 0:
+		return c, fmt.Errorf("HSTS_MAX_AGE must be >= 0")
 	case c.BcryptCost < 4 || c.BcryptCost > 16:
 		return c, fmt.Errorf("BCRYPT_COST must be within [4,16]")
 	case c.AccessTokenTTL <= 0 || c.RefreshTokenTTL <= c.AccessTokenTTL:
@@ -229,6 +247,8 @@ func Load() (Config, error) {
 
 // ValidateServe checks settings only the API server needs, so one-shot commands
 // (migrate, healthcheck) don't have to be given the JWT secret.
+func (c Config) TLSEnabled() bool { return c.TLSCertFile != "" }
+
 func (c Config) ValidateServe() error {
 	if len(c.JWTSecret) < 32 {
 		return fmt.Errorf("JWT_SECRET is required and must be at least 32 bytes (try: openssl rand -hex 32)")
```

**Create** `internal/devcert/devcert.go`

```go
// Package devcert creates a throwaway certificate authority and server
// certificates signed by it, for LOCAL DEVELOPMENT and tests only.
//
// Why a CA instead of one self-signed cert? Clients then trust ONE file (ca.pem)
// and verification works exactly as in production (chain + hostname check), so
// you never need "--insecure" / InsecureSkipVerify, which is the habit that
// leaks into production code. It also lets us re-issue the server certificate
// (rotation) without clients having to change what they trust.
package devcert

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"errors"
	"fmt"
	"math/big"
	"net"
	"os"
	"path/filepath"
	"time"
)

// CA is a certificate authority able to sign server certificates.
type CA struct {
	cert    *x509.Certificate
	key     *ecdsa.PrivateKey
	CertPEM []byte // give this to clients as their trust root
	KeyPEM  []byte // the CA's PRIVATE key: whoever has it can mint trusted certs
}

// Server is an issued server certificate.
type Server struct {
	CertPEM []byte
	KeyPEM  []byte // the server's PRIVATE key
}

// NewCA creates a CA valid for one year.
func NewCA(now time.Time) (*CA, error) {
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, err
	}
	tmpl := &x509.Certificate{
		SerialNumber:          serial(),
		Subject:               pkix.Name{CommonName: "rest-go dev CA", Organization: []string{"rest-go (development only)"}},
		NotBefore:             now.Add(-time.Minute),
		NotAfter:              now.Add(365 * 24 * time.Hour),
		IsCA:                  true,
		BasicConstraintsValid: true,
		MaxPathLen:            0, // may sign leaf certs, not further CAs
		MaxPathLenZero:        true,
		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
	}
	der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
	if err != nil {
		return nil, err
	}
	cert, err := x509.ParseCertificate(der)
	if err != nil {
		return nil, err
	}
	keyDER, err := x509.MarshalPKCS8PrivateKey(key)
	if err != nil {
		return nil, err
	}
	return &CA{
		cert: cert, key: key,
		CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
		KeyPEM:  pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}),
	}, nil
}

// LoadCA restores a CA written by WriteDir.
func LoadCA(certPEM, keyPEM []byte) (*CA, error) {
	cb, _ := pem.Decode(certPEM)
	kb, _ := pem.Decode(keyPEM)
	if cb == nil || kb == nil {
		return nil, errors.New("invalid CA PEM")
	}
	cert, err := x509.ParseCertificate(cb.Bytes)
	if err != nil {
		return nil, err
	}
	k, err := x509.ParsePKCS8PrivateKey(kb.Bytes)
	if err != nil {
		return nil, err
	}
	key, ok := k.(*ecdsa.PrivateKey)
	if !ok {
		return nil, errors.New("CA key is not ECDSA")
	}
	return &CA{cert: cert, key: key, CertPEM: certPEM, KeyPEM: keyPEM}, nil
}

// IssueServer signs a server certificate valid for `validity` whose Subject
// Alternative Names are hosts (DNS names and/or IPs).
//
// SAN is what clients check. Since 2017 browsers and Go ignore the old "Common
// Name" field: a cert without a matching SAN fails with "certificate is not
// valid for any names" even if the CN matches.
func (ca *CA) IssueServer(hosts []string, now time.Time, validity time.Duration) (Server, error) {
	if len(hosts) == 0 {
		return Server{}, errors.New("at least one host required")
	}
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return Server{}, err
	}
	tmpl := &x509.Certificate{
		SerialNumber: serial(),
		Subject:      pkix.Name{CommonName: hosts[0]},
		NotBefore:    now.Add(-time.Minute),
		NotAfter:     now.Add(validity),
		KeyUsage:     x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}
	for _, h := range hosts {
		if ip := net.ParseIP(h); ip != nil {
			tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
		} else {
			tmpl.DNSNames = append(tmpl.DNSNames, h)
		}
	}
	der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &key.PublicKey, ca.key)
	if err != nil {
		return Server{}, err
	}
	keyDER, err := x509.MarshalPKCS8PrivateKey(key)
	if err != nil {
		return Server{}, err
	}
	return Server{
		CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
		KeyPEM:  pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}),
	}, nil
}

func serial() *big.Int {
	n, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127))
	if err != nil {
		panic(err)
	}
	return n
}

// WriteCA writes ca.pem and ca-key.pem; WriteServer writes server.pem and
// server-key.pem.
//
// DEV ONLY: files are written 0644 (group/world readable) because bind-mounted
// into containers they must be readable by non-root users (the api runs as uid
// 65532). In production the orchestrator mounts secrets with proper ownership,
// and the CA key never leaves the CA.
func WriteCA(dir string, ca *CA) error {
	return writeFiles(dir, map[string][]byte{"ca.pem": ca.CertPEM, "ca-key.pem": ca.KeyPEM})
}

func WriteServer(dir string, s Server) error {
	return writeFiles(dir, map[string][]byte{"server.pem": s.CertPEM, "server-key.pem": s.KeyPEM})
}

func writeFiles(dir string, files map[string][]byte) error {
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	for name, data := range files {
		if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil {
			return fmt.Errorf("write %s: %w", name, err)
		}
	}
	return nil
}

// ReadCA loads the CA previously written to dir.
func ReadCA(dir string) (*CA, error) {
	c, err1 := os.ReadFile(filepath.Join(dir, "ca.pem"))
	k, err2 := os.ReadFile(filepath.Join(dir, "ca-key.pem"))
	if err := errors.Join(err1, err2); err != nil {
		return nil, err
	}
	return LoadCA(c, k)
}
```

**Change** `internal/middleware/middleware.go`

```diff
--- a/internal/middleware/middleware.go
+++ b/internal/middleware/middleware.go
@@ -58,6 +58,21 @@ func SecurityHeaders(next http.Handler) http.Handler {
 	})
 }
 
+// HSTS tells browsers (and well-behaved clients) to use HTTPS only for this host
+// for the next maxAge, even if someone types or links http://. It defeats
+// "SSL stripping" downgrade attacks. Only send it over HTTPS: browsers ignore it
+// on plain HTTP, and a long max-age on a host that later loses HTTPS locks
+// users out. Start small when rolling out, and treat "preload" as irreversible.
+func HSTS(maxAge time.Duration) func(http.Handler) http.Handler {
+	value := "max-age=" + strconv.Itoa(int(maxAge.Seconds()))
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Strict-Transport-Security", value)
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
 // Timeout puts a deadline on each request's context. Handlers pass r.Context()
 // to the store, so a slow query is cancelled when the deadline hits instead of
 // running on after the client has given up.
```

**Change** `internal/observability/metrics.go`

```diff
--- a/internal/observability/metrics.go
+++ b/internal/observability/metrics.go
@@ -63,6 +63,15 @@ func (m *Metrics) RateLimited(limiter string) func() {
 	return c.Inc
 }
 
+// TrackCertExpiry exports the serving certificate's expiry as a gauge, read at
+// scrape time (so it follows hot reloads). Alert on it BEFORE it hits zero.
+func (m *Metrics) TrackCertExpiry(expiry func() time.Time) {
+	m.Registry.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
+		Name: "tls_certificate_expiry_timestamp_seconds",
+		Help: "Unix time at which the serving TLS certificate expires.",
+	}, func() float64 { return float64(expiry().Unix()) }))
+}
+
 // Handler serves the registry in Prometheus text format.
 func (m *Metrics) Handler() http.Handler {
 	return promhttp.HandlerFor(m.Registry, promhttp.HandlerOpts{})
```

**Create** `internal/tlsutil/tlsutil.go`

```go
// Package tlsutil builds the server's TLS configuration.
package tlsutil

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"log/slog"
	"os"
	"sync"
	"time"
)

// Reloader serves a certificate that can be replaced WITHOUT restarting.
//
// Certificates expire (Let's Encrypt: 90 days) and are renewed by an external
// process (certbot, cert-manager, your secret store) that rewrites the files. A
// server that loads them once at startup keeps serving the old, soon-expired
// cert until someone restarts it: a classic outage. Here every handshake calls
// GetCertificate, which re-reads the files when their modification time changes.
type Reloader struct {
	certFile, keyFile string
	log               *slog.Logger
	interval          time.Duration
	now               func() time.Time

	mu        sync.RWMutex
	cert      *tls.Certificate
	leaf      *x509.Certificate
	certMod   time.Time
	keyMod    time.Time
	lastCheck time.Time
}

// NewReloader loads the pair immediately, so a missing or invalid cert fails at
// startup (loudly) rather than on the first client connection.
func NewReloader(certFile, keyFile string, log *slog.Logger) (*Reloader, error) {
	r := &Reloader{certFile: certFile, keyFile: keyFile, log: log, interval: 5 * time.Second, now: time.Now}
	if err := r.load(); err != nil {
		return nil, err
	}
	r.lastCheck = r.now()
	return r, nil
}

func (r *Reloader) load() error {
	pair, err := tls.LoadX509KeyPair(r.certFile, r.keyFile) // also checks cert matches key
	if err != nil {
		return fmt.Errorf("load tls keypair: %w", err)
	}
	leaf, err := x509.ParseCertificate(pair.Certificate[0])
	if err != nil {
		return fmt.Errorf("parse certificate: %w", err)
	}
	cm, err1 := modTime(r.certFile)
	km, err2 := modTime(r.keyFile)
	if err1 != nil || err2 != nil {
		return fmt.Errorf("stat tls files: %v %v", err1, err2)
	}
	r.mu.Lock()
	r.cert, r.leaf, r.certMod, r.keyMod = &pair, leaf, cm, km
	r.mu.Unlock()
	return nil
}

func modTime(path string) (time.Time, error) {
	fi, err := os.Stat(path) // follows symlinks (Kubernetes swaps a symlink on secret update)
	if err != nil {
		return time.Time{}, err
	}
	return fi.ModTime(), nil
}

// GetCertificate is plugged into tls.Config; it runs on every handshake.
func (r *Reloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
	r.maybeReload()
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.cert, nil
}

func (r *Reloader) maybeReload() {
	r.mu.Lock()
	if r.now().Sub(r.lastCheck) < r.interval { // stat at most every few seconds, not per handshake
		r.mu.Unlock()
		return
	}
	r.lastCheck = r.now()
	cm, err1 := modTime(r.certFile)
	km, err2 := modTime(r.keyFile)
	changed := err1 == nil && err2 == nil && (!cm.Equal(r.certMod) || !km.Equal(r.keyMod))
	r.mu.Unlock()
	if !changed {
		return
	}
	// On failure KEEP SERVING THE OLD CERT. A half-written file (cert replaced,
	// key not yet) must not take the site down; the next check retries.
	if err := r.load(); err != nil {
		r.log.Error("tls certificate reload failed; keeping previous certificate", "err", err)
		return
	}
	r.log.Info("tls certificate reloaded", "expires", r.Expiry())
}

// Expiry is the current certificate's NotAfter (exported as a metric).
func (r *Reloader) Expiry() time.Time {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.leaf.NotAfter
}

// Config returns the server TLS settings.
//
// Deliberately minimal: Go's defaults are maintained by the Go team and are
// better than a hand-written cipher list (old lists age badly and have shipped
// weak suites in many products). We only set the floor:
//   - TLS 1.2 minimum: TLS 1.0/1.1 are deprecated (RFC 8996) and have known attacks.
//   - TLS 1.3 is used automatically with modern clients; pass min=tls.VersionTLS13
//     when ALL clients are known to support it.
func (r *Reloader) Config(minVersion uint16) *tls.Config {
	return &tls.Config{
		MinVersion:     minVersion,
		GetCertificate: r.GetCertificate,
		// HTTP/2 is negotiated via ALPN; net/http adds "h2" itself when serving TLS.
	}
}

// ParseMinVersion maps "1.2" / "1.3" to the crypto/tls constant.
func ParseMinVersion(s string) (uint16, error) {
	switch s {
	case "", "1.2":
		return tls.VersionTLS12, nil
	case "1.3":
		return tls.VersionTLS13, nil
	}
	return 0, fmt.Errorf("TLS_MIN_VERSION must be 1.2 or 1.3, got %q", s)
}
```

**Create** `cmd/devcert/main.go`

```go
// Command devcert generates a local CA and server certificate for development.
//
//	go run ./cmd/devcert -out certs            # new CA + server cert
//	go run ./cmd/devcert -out certs -renew     # new server cert, SAME CA (simulates rotation)
package main

import (
	"flag"
	"fmt"
	"os"
	"strings"
	"time"

	"go-rest/internal/devcert"
)

func main() {
	out := flag.String("out", "certs", "output directory")
	hosts := flag.String("hosts", "localhost,127.0.0.1,::1,api,postgres", "comma-separated DNS names / IPs for the certificate")
	days := flag.Int("days", 90, "server certificate validity in days")
	renew := flag.Bool("renew", false, "reuse the existing CA in -out and only re-issue the server certificate")
	flag.Parse()

	if err := run(*out, strings.Split(*hosts, ","), time.Duration(*days)*24*time.Hour, *renew); err != nil {
		fmt.Fprintln(os.Stderr, "devcert:", err)
		os.Exit(1)
	}
	fmt.Printf("wrote %s/ (server cert valid %d days)\ntrust it with: curl --cacert %s/ca.pem https://localhost:7800/...\n", *out, *days, *out)
}

func run(dir string, hosts []string, validity time.Duration, renew bool) error {
	var (
		ca  *devcert.CA
		err error
	)
	if renew {
		ca, err = devcert.ReadCA(dir)
	} else {
		if ca, err = devcert.NewCA(time.Now()); err == nil {
			err = devcert.WriteCA(dir, ca)
		}
	}
	if err != nil {
		return err
	}
	srv, err := ca.IssueServer(hosts, time.Now(), validity)
	if err != nil {
		return err
	}
	return devcert.WriteServer(dir, srv)
}
```

**Change** `cmd/rest/main.go`

```diff
--- a/cmd/rest/main.go
+++ b/cmd/rest/main.go
@@ -25,6 +25,7 @@ import (
 	"go-rest/internal/ratelimit"
 	"go-rest/internal/routes"
 	"go-rest/internal/storage"
+	"go-rest/internal/tlsutil"
 )
 
 // One binary, three modes:
@@ -264,6 +265,19 @@ func serve() error {
 		AuthLimit:    authLimit,
 	})
 
+	// TLS: load the certificate now (fail fast) and keep it hot-reloadable.
+	var tlsReloader *tlsutil.Reloader
+	middlewares := []func(http.Handler) http.Handler{}
+	if cfg.TLSEnabled() {
+		if tlsReloader, err = tlsutil.NewReloader(cfg.TLSCertFile, cfg.TLSKeyFile, log); err != nil {
+			return err
+		}
+		metrics.TrackCertExpiry(tlsReloader.Expiry)
+		if cfg.HSTSMaxAge > 0 {
+			middlewares = append(middlewares, middleware.HSTS(cfg.HSTSMaxAge))
+		}
+	}
+
 	// First listed = outermost. ORDER MATTERS:
 	//  1. otelhttp, RequestID, Timeout all COPY the request (r.WithContext), so
 	//     they go first.
@@ -276,7 +290,7 @@ func serve() error {
 	//     like any response, yet rejected requests never reach the mux.
 	api := &http.Server{
 		Addr: cfg.Addr,
-		Handler: middleware.Chain(mux, append([]func(http.Handler) http.Handler{
+		Handler: middleware.Chain(mux, append(append(middlewares, []func(http.Handler) http.Handler{
 			func(next http.Handler) http.Handler { return otelhttp.NewHandler(next, "http.server") },
 			middleware.RequestID,
 			middleware.Gzip,
@@ -286,7 +300,7 @@ func serve() error {
 			middleware.Logging(log),
 			metrics.Middleware,
 			middleware.Recover(log),
-		}, limiters...)...),
+		}...), limiters...)...),
 		// The zero-value http.Server has NO timeouts, so slow or malicious
 		// clients (e.g. Slowloris) can hold connections open forever.
 		ReadHeaderTimeout: cfg.ReadHeaderTimeout,
@@ -295,6 +309,14 @@ func serve() error {
 		IdleTimeout:       cfg.IdleTimeout,
 	}
 
+	if tlsReloader != nil {
+		minVer, err := tlsutil.ParseMinVersion(cfg.TLSMinVer)
+		if err != nil {
+			return err
+		}
+		api.TLSConfig = tlsReloader.Config(minVer)
+	}
+
 	// Operational endpoints live on a SEPARATE port: metrics and health are for
 	// Prometheus/orchestrators, and must never be exposed through the public
 	// listener / ingress.
@@ -307,7 +329,15 @@ func serve() error {
 	errc := make(chan error, 2)
 	for name, srv := range map[string]*http.Server{"api": api, "admin": admin} {
 		go func() {
-			log.Info("listening", "server", name, "addr", srv.Addr)
+			log.Info("listening", "server", name, "addr", srv.Addr, "tls", srv.TLSConfig != nil)
+			if srv.TLSConfig != nil {
+				// Empty file names: certificates come from TLSConfig.GetCertificate.
+				// There is intentionally NO plain-HTTP listener or redirect for the
+				// API: a client's first request would already carry its Authorization
+				// header in clear text before any redirect could happen.
+				errc <- srv.ListenAndServeTLS("", "")
+				return
+			}
 			errc <- srv.ListenAndServe()
 		}()
 	}
```

**Replace the contents of** `.gitignore` **with**

```plaintext
users.db
users.db-shm
users.db-wal
bin/
.env
certs/
```

**Change** `deploy/prometheus/alerts.yml`

```diff
--- a/deploy/prometheus/alerts.yml
+++ b/deploy/prometheus/alerts.yml
@@ -41,3 +41,11 @@ groups:
         for: 5m
         labels: { severity: page }
         annotations: { summary: "Sustained throttling of login attempts; possible password guessing or credential stuffing" }
+
+  - name: tls
+    rules:
+      - alert: TLSCertificateExpiringSoon   # renewal automation has probably failed
+        expr: tls_certificate_expiry_timestamp_seconds - time() < 14 * 24 * 3600
+        for: 1h
+        labels: { severity: page }
+        annotations: { summary: "TLS certificate expires in under 14 days" }
```

**Change** `docker-compose.yml`

```diff
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,4 +1,4 @@
-# Full local stack. `make up` -> API :7800, Grafana :3000, Prometheus :9091, Jaeger :16686
+# Full local stack. `make up` -> API https://localhost:7800, Grafana :3000, Prometheus :9091, Jaeger :16686
 name: rest-go
 
 x-logging: &logging   # cap container log files so disks don't fill up
@@ -15,6 +15,23 @@ services:
       POSTGRES_DB: ${POSTGRES_DB:-app}
     volumes:
       - pgdata:/var/lib/postgresql/data      # named volume: data survives `down`
+      - ./certs:/certs:ro                    # dev CA + server cert (`make certs`)
+    # Postgres refuses a private key that is group/world readable or not owned by
+    # the postgres user, which bind mounts can't guarantee, so copy it into place
+    # first. Then start Postgres with TLS on and a 1.2 floor.
+    command:
+      - sh
+      - -c
+      - |
+        cp /certs/server.pem /var/lib/postgresql/server.crt
+        cp /certs/server-key.pem /var/lib/postgresql/server.key
+        chown postgres:postgres /var/lib/postgresql/server.crt /var/lib/postgresql/server.key
+        chmod 600 /var/lib/postgresql/server.key
+        exec docker-entrypoint.sh postgres \
+          -c ssl=on \
+          -c ssl_cert_file=/var/lib/postgresql/server.crt \
+          -c ssl_key_file=/var/lib/postgresql/server.key \
+          -c ssl_min_protocol_version=TLSv1.2
     ports:
       - "127.0.0.1:5432:5432"                # localhost only, never 0.0.0.0
     healthcheck:                             # lets other services wait until it is READY
@@ -31,7 +48,8 @@ services:
     build: .
     command: ["migrate"]
     environment: &dburl
-      DATABASE_URL: postgres://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-app}?sslmode=disable
+      DATABASE_URL: postgres://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-app}?sslmode=verify-full&sslrootcert=/certs/ca.pem
+    volumes: ["./certs:/certs:ro"]
     depends_on:
       postgres: { condition: service_healthy }
     restart: "no"
@@ -43,6 +61,10 @@ services:
     environment:
       <<: *dburl
       ADDR: ":7800"
+      # HTTPS. The certificate is re-read when the files change (no restart needed).
+      TLS_CERT_FILE: /certs/server.pem
+      TLS_KEY_FILE: /certs/server-key.pem
+      # TLS_MIN_VERSION: "1.3"      # when every client supports it
       # Required, >= 32 bytes. `make up` generates one into .env on first run.
       JWT_SECRET: ${JWT_SECRET:?JWT_SECRET missing; run `make .env` or set it in .env}
       ADMIN_ADDR: ":9090"
@@ -59,8 +81,9 @@ services:
       LOGIN_MAX_FAILURES:
       LOGIN_FAILURE_REFILL:
       TRUSTED_PROXIES:              # set e.g. 10.0.0.0/8 when behind a load balancer
+    volumes: ["./certs:/certs:ro"]
     ports:
-      - "7800:7800"                          # public API only; admin port 9090 stays internal
+      - "7800:7800"                          # public API (HTTPS); admin port 9090 stays internal
     depends_on:
       migrate: { condition: service_completed_successfully }
       jaeger: { condition: service_started }
@@ -85,7 +108,8 @@ services:
   postgres-exporter:                         # turns Postgres internals into metrics
     image: prometheuscommunity/postgres-exporter:latest
     environment:
-      DATA_SOURCE_NAME: postgres://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-app}?sslmode=disable
+      DATA_SOURCE_NAME: postgres://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-app}?sslmode=verify-full&sslrootcert=/certs/ca.pem
+    volumes: ["./certs:/certs:ro"]
     depends_on:
       postgres: { condition: service_healthy }
     logging: *logging
```

**Change** `makefile`

```diff
--- a/makefile
+++ b/makefile
@@ -1,8 +1,9 @@
-.PHONY: admin db run build test test-integration up down logs reset migrate psql load clean
+.PHONY: admin certs rotate-certs db run build test test-integration up down logs reset migrate psql load clean
 
 BINARY := bin/server
 # Local (non-Docker) default; matches the compose Postgres published on localhost.
-export DATABASE_URL ?= postgres://app:change-me@localhost:5432/app?sslmode=disable
+# verify-full = encrypted AND the server's certificate + hostname are checked.
+export DATABASE_URL ?= postgres://app:change-me@localhost:5432/app?sslmode=verify-full&sslrootcert=$(CURDIR)/certs/ca.pem
 # Local `make run` needs a secret too; any 32+ byte value works for development.
 export JWT_SECRET ?= dev-only-secret-dev-only-secret-0123456789
 TEST_DATABASE_URL ?= $(DATABASE_URL)
@@ -22,12 +23,12 @@ test:                     ## unit tests (database tests are skipped)
 	go vet ./...
 	go test -race ./...
 
-test-integration:         ## all tests incl. database tests, against compose Postgres
+test-integration: certs/server.pem ## all tests incl. database tests, against compose Postgres (over TLS)
 	docker compose up -d --wait postgres
 	TEST_DATABASE_URL="$(TEST_DATABASE_URL)" go test -race -count=1 ./...
 
 # ---- Docker ------------------------------------------------------------------
-db:                       ## only Postgres (for running the API with `make run`)
+db: certs/server.pem      ## only Postgres (for running the API with `make run`)
 	docker compose up -d --wait postgres
 
 .env:                     ## create .env with a fresh random JWT secret (never overwrites)
@@ -38,7 +39,15 @@ db:                       ## only Postgres (for running the API with `make run`)
 admin:                    ## create/promote an admin:  ADMIN_EMAIL=a@b.co ADMIN_PASSWORD=... make admin
 	docker compose run --rm --no-deps -e ADMIN_EMAIL -e ADMIN_PASSWORD api create-admin
 
-up: .env                  ## whole stack: API + Postgres + Prometheus + Grafana + Jaeger + Loki
+certs/server.pem:         ## dev CA + server certificate (git-ignored)
+	go run ./cmd/devcert -out certs
+
+certs: certs/server.pem
+
+rotate-certs:             ## re-issue the server cert from the same CA; the running API picks it up
+	go run ./cmd/devcert -out certs -renew
+
+up: .env certs/server.pem                  ## whole stack: API + Postgres + Prometheus + Grafana + Jaeger + Loki
 	docker compose up -d --build --wait
 
 down:                     ## stop, KEEP data
```

**Replace the contents of** `scripts/load.sh` **with**

```bash
#!/usr/bin/env bash
# Generates mixed traffic (including errors) so the dashboards have something to show.
# Registers a throwaway user per run, then uses its token.
set -euo pipefail
API=${API:-https://localhost:7800}
# Trust the dev CA instead of disabling verification.
C="curl --cacert ${CA:-certs/ca.pem}"
EMAIL="load$RANDOM$RANDOM@example.com"
TOKEN=$($C -s -XPOST "$API/auth/register" \
  -d "{\"name\":\"load\",\"email\":\"$EMAIL\",\"password\":\"correct-horse-battery\"}" |
  python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
H="Authorization: Bearer $TOKEN"
for i in $(seq 1 ${N:-100}); do
  $C -s -o /dev/null -H "$H" -XPOST "$API/orders" -d '{"item":"book","quantity":2}'
  $C -s -o /dev/null -H "$H" "$API/orders"
  $C -s -o /dev/null -H "$H" "$API/orders/$((RANDOM % 500 + 1))"        # mostly 404s
  $C -s -o /dev/null -H "$H" -XPOST "$API/orders" -d '{"item":""}'      # 422s
  $C -s -o /dev/null "$API/orders"                                      # 401s
done
echo "done (user $EMAIL)"
```

## Try it

```bash
make certs           # creates certs/ (CA + server certificate)
make up              # API is now https://localhost:7800
```

```bash
$ curl --cacert certs/ca.pem -D- -o /dev/null https://localhost:7800/orders
HTTP/2 401
strict-transport-security: max-age=15552000
www-authenticate: Bearer realm="api"

$ curl -sv --cacert certs/ca.pem -o /dev/null https://localhost:7800/orders 2>&1 | grep -E "SSL connection|ALPN|subjectAltName"
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256
* ALPN: server accepted h2
*  subjectAltName: host "localhost" matched cert's "localhost"
```

And the failure cases, each of which must fail:

```bash
$ curl https://localhost:7800/orders                       # we don't trust the CA
curl: (60) SSL certificate problem: unable to get local issuer certificate

$ curl --cacert certs/ca.pem --tls-max 1.1 https://localhost:7800/   # old protocol
curl: (35) ... protocol version

$ curl --cacert certs/ca.pem --resolve evil.example.com:7800:127.0.0.1 https://evil.example.com:7800/
curl: (60) SSL: no alternative certificate subject name matches target host name 'evil.example.com'

$ curl http://localhost:7800/orders                        # plaintext to the TLS port
400 Bad Request
```

Is the **database** link encrypted? Ask Postgres (the `psql` row is my own Unix-socket session; every application connection is TLS):

```plaintext
app=# select a.application_name, a.client_addr, s.ssl, s.version
        from pg_stat_ssl s join pg_stat_activity a using(pid) where a.datname='app';
              | 192.168.107.6 | t | TLSv1.3
              | 192.168.107.6 | t | TLSv1.3
              | 192.168.107.6 | t | TLSv1.3
              | 192.168.107.7 | t | TLSv1.3
 psql         |               | f |
```

### Rotate the certificate live

```bash
$ echo | openssl s_client -connect localhost:7800 -servername localhost -CAfile certs/ca.pem 2>/dev/null | openssl x509 -noout -serial
serial=2802FD58EF459A56AD7964A56E03ABA2

$ make rotate-certs          # new server certificate, same CA; the API is NOT restarted
$ sleep 6
$ echo | openssl s_client ... | openssl x509 -noout -serial
serial=7069B26B98024B392B37CDD32AAAFF81        # different: picked up live
```

The log shows one line: `tls certificate reloaded`.

## Terminate TLS in the app, or in front of it?

|  | App terminates TLS (this series) | Load balancer / ingress terminates |
| --- | --- | --- |
| Pros | encrypted end to end, simple for small deployments | central certificate management, offloads CPU, WAF features |
| Cons | every instance needs certificates | plain HTTP behind the LB unless re-encrypted |

If a proxy terminates TLS for you: leave `TLS_CERT_FILE` empty, set `TRUSTED_PROXIES` (Part 10), and add HSTS at the proxy. Never trust `X-Forwarded-Proto` unless your own proxy sets it.

## What we did not cover

- **Mutual TLS (mTLS)**: clients present certificates too; ideal for service-to-service calls. In Go: `ClientAuth: tls.RequireAndVerifyClientCert` and `ClientCAs`.
- **Internal observability hops** (traces to Jaeger, logs to Loki, scrapes) are plain HTTP inside the private compose network. In production, use a service mesh or enable TLS on those components.
- **Automatic issuance** (ACME): certbot, cert-manager, or `golang.org/x/crypto/acme/autocert`. Our reloader will pick up whatever they write.
- **CORS** for browser clients is unrelated to TLS and isn't configured.

## Debugging TLS: a pocket guide

```bash
openssl s_client -connect localhost:7800 -servername localhost -CAfile certs/ca.pem   # handshake + chain
openssl x509 -in certs/server.pem -noout -text | grep -A1 "Alternative Name"          # SANs
curl -v --cacert certs/ca.pem https://localhost:7800/                                  # protocol, cipher, ALPN
```

Typical errors: `certificate is not valid for any names` (missing SAN) · `unknown authority` (client lacks the CA) · `certificate has expired` · `tls: bad certificate` (client rejected us) · `http: TLS handshake error ... EOF` (a client spoke plain HTTP to the TLS port).

## Recap

- TLS = confidentiality + integrity + **authenticity**; authenticity depends on verifying the certificate chain *and hostname*.
- Never disable verification. Use a local CA in development so you never need to.
- TLS 1.2 floor, Go's default ciphers, HSTS, no HTTP listener for an API.
- Hot-reload certificates; keep serving the old one if the new files are broken; monitor expiry.
- `sslmode=verify-full` for the database; `require` doesn't stop a man-in-the-middle.

## Exercises

1. Set `TLS_MIN_VERSION=1.3` and re-run the `--tls-max 1.2` curl. What do you see?
2. Make the API require client certificates (mTLS) on the *admin* port and scrape it from Prometheus with a client cert.
3. Edit the SANs in `devcert` to remove `localhost` and watch `curl` fail. Read the error closely: you'll see it again in production someday.
