Frontend Labs
⋯

Part 9 — Build the lifecycle in Go

Parts 1–8 explained the lifecycle and how to operate it. This part has you build the server side of it: a production-shaped HTTPS server, a reverse proxy, the resilience mechanisms between services, outbound request safety, service-level objectives, and streaming. Each chapter centres on one Go program using only the standard library. Each was run while writing this guide, and the outputs are real.

These chapters assume you've read the matching concept chapters (named at the start of each) and know basic Go (the Go guide, Parts I–II).

Chapter 19 — A production HTTPS server in Go: TLS, timeouts, headers, and shutdown

In one sentence

A production HTTPS server is mostly configuration: modern TLS, a timeout on every phase of a connection, security headers on every response, health endpoints, structured logs, and a shutdown that drains instead of dropping.

Why this matters

Go makes it easy to write a server that works: http.ListenAndServeTLS is one line. That one-liner has no timeouts at all, so a client that sends its headers slowly holds a connection (and a goroutine, and memory) forever. It adds no security headers, and it drops in-flight requests when the process stops. Every one of those defaults has caused real incidents.

How it actually works

The four server timeouts, mapped onto a connection's life:

 accept ──[ReadHeaderTimeout]── headers read ──[ReadTimeout: whole body]── handler runs
        ──[WriteTimeout: until response done]── idle keep-alive ──[IdleTimeout]── next request / close
Field Protects against Typical value
ReadHeaderTimeout Slowloris: clients trickling headers 5–10 s
ReadTimeout Slow request bodies ("slow POST") 30 s (more for uploads)
WriteTimeout Slow readers holding responses open 30 s (but see Chapter 24 for streams)
IdleTimeout Idle keep-alive connections piling up 60–120 s (longer than your LB's idle timeout)
MaxHeaderBytes Huge header floods 32 KB–1 MB

TLS configuration in Go is mostly "don't touch it". Go's defaults prefer TLS 1.3, choose strong cipher suites, and get updated with each release. Set MinVersion: tls.VersionTLS12 (TLS 1.0 and 1.1 are formally deprecated by RFC 8996) and leave the rest alone unless a compliance standard requires otherwise.

Security headers (Chapter 12) are best set in one middleware, so no handler can forget them. HSTS is only meaningful over HTTPS, which is why the middleware sets it only when r.TLS != nil.

Build it in Go (45 min) — the server

// httpsserver: a production-shaped HTTPS server using only the standard
// library -- modern TLS, every timeout set, security headers, HTTP->HTTPS
// redirect, health endpoints, structured logs, and graceful shutdown.
//
//	go run ./httpsserver                         # self-signed cert, :8443 + :8080 redirect
//	go run ./httpsserver -cert c.pem -key k.pem  # your own certificate
//	go run ./httpsserver -plain :8080 -no-timeouts   # plain HTTP, NO timeouts (the Slowloris lab)
package main

import (
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/tls"
	"crypto/x509"
	"crypto/x509/pkix"
	"errors"
	"flag"
	"fmt"
	"log/slog"
	"math/big"
	"net"
	"net/http"
	"os"
	"os/signal"
	"sync/atomic"
	"syscall"
	"time"
)

func main() {
	addr := flag.String("addr", ":8443", "HTTPS listen address")
	redirect := flag.String("redirect", ":8080", "plain-HTTP listener that only redirects to HTTPS ('' to disable)")
	plain := flag.String("plain", "", "serve the app over PLAIN HTTP on this address instead (lab use)")
	certFile := flag.String("cert", "", "certificate PEM (default: generate self-signed)")
	keyFile := flag.String("key", "", "private key PEM")
	noTimeouts := flag.Bool("no-timeouts", false, "DANGEROUS: zero timeouts, to demonstrate Slowloris")
	flag.Parse()

	log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	var ready atomic.Bool
	var open atomic.Int64

	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) }) // liveness
	mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) {                           // readiness
		if !ready.Load() {
			http.Error(w, "not ready", http.StatusServiceUnavailable)
			return
		}
		w.Write([]byte("ready"))
	})
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "hello over %s (%s)\n", r.Proto, tlsVersion(r))
	})

	srv := &http.Server{
		Addr:    *addr,
		Handler: securityHeaders(logRequests(log, mux)),
		// The four timeouts. Go's zero values mean "wait forever", which is
		// exactly what Slowloris exploits.
		ReadHeaderTimeout: 5 * time.Second,   // client must finish sending headers
		ReadTimeout:       30 * time.Second,  // ...and the whole request body
		WriteTimeout:      30 * time.Second,  // we must finish the response
		IdleTimeout:       120 * time.Second, // keep-alive connections between requests
		MaxHeaderBytes:    32 << 10,          // 32 KB of headers is plenty
		TLSConfig: &tls.Config{
			MinVersion: tls.VersionTLS12, // TLS 1.0/1.1 are deprecated (RFC 8996)
			// Go's defaults pick strong cipher suites and prefer TLS 1.3;
			// resist the urge to hand-tune them.
		},
		ConnState: func(_ net.Conn, s http.ConnState) { // count open connections
			switch s {
			case http.StateNew:
				open.Add(1)
			case http.StateClosed, http.StateHijacked:
				open.Add(-1)
			}
		},
		ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
	}
	if *noTimeouts {
		srv.ReadHeaderTimeout, srv.ReadTimeout, srv.WriteTimeout, srv.IdleTimeout = 0, 0, 0, 0
		log.Warn("timeouts disabled: this server can be held open forever")
	}
	go func() { // report open connections every 5s, so the Slowloris lab is visible
		for range time.Tick(5 * time.Second) {
			log.Info("connections", "open", open.Load())
		}
	}()

	if *plain != "" {
		srv.Addr = *plain
		go func() { log.Error("serve", "err", srv.ListenAndServe()) }()
	} else {
		cert, err := loadOrGenerate(*certFile, *keyFile)
		if err != nil {
			log.Error("certificate", "err", err)
			os.Exit(1)
		}
		srv.TLSConfig.Certificates = []tls.Certificate{cert}
		go func() {
			if err := srv.ListenAndServeTLS("", ""); !errors.Is(err, http.ErrServerClosed) {
				log.Error("serve", "err", err)
				os.Exit(1)
			}
		}()
		if *redirect != "" {
			go http.ListenAndServe(*redirect, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				host, _, _ := net.SplitHostPort(r.Host)
				if host == "" {
					host = r.Host
				}
				_, port, _ := net.SplitHostPort(*addr)
				http.Redirect(w, r, "https://"+net.JoinHostPort(host, port)+r.URL.RequestURI(), http.StatusMovedPermanently)
			}))
		}
	}
	ready.Store(true)
	log.Info("listening", "https", *addr, "plain", *plain, "redirect", *redirect)

	// Graceful shutdown: fail readiness, wait, drain, exit (TCP/IP guide Ch 57).
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
	defer stop()
	<-ctx.Done()
	ready.Store(false)
	log.Info("draining")
	time.Sleep(2 * time.Second)
	sctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	srv.Shutdown(sctx)
	log.Info("stopped")
}

// securityHeaders sets the browser-side protections from Chapter 12 on every response.
func securityHeaders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		h := w.Header()
		if r.TLS != nil { // HSTS is only meaningful (and only honoured) over HTTPS
			h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
		}
		h.Set("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'; base-uri 'none'")
		h.Set("X-Content-Type-Options", "nosniff")
		h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
		h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
		next.ServeHTTP(w, r)
	})
}

type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (s *statusRecorder) WriteHeader(code int) { s.status = code; s.ResponseWriter.WriteHeader(code) }

func logRequests(log *slog.Logger, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rec := &statusRecorder{w, http.StatusOK}
		next.ServeHTTP(rec, r)
		log.Info("request", "method", r.Method, "path", r.URL.Path, "proto", r.Proto,
			"status", rec.status, "ms", time.Since(start).Milliseconds(), "remote", r.RemoteAddr)
	})
}

func tlsVersion(r *http.Request) string {
	if r.TLS == nil {
		return "no TLS"
	}
	return tls.VersionName(r.TLS.Version) + ", ALPN " + r.TLS.NegotiatedProtocol
}

func loadOrGenerate(certFile, keyFile string) (tls.Certificate, error) {
	if certFile != "" {
		return tls.LoadX509KeyPair(certFile, keyFile)
	}
	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return tls.Certificate{}, err
	}
	serial, _ := rand.Int(rand.Reader, big.NewInt(1<<62))
	tmpl := &x509.Certificate{
		SerialNumber: serial,
		Subject:      pkix.Name{CommonName: "localhost"},
		DNSNames:     []string{"localhost"},
		IPAddresses:  []net.IP{net.ParseIP("127.0.0.1")},
		NotBefore:    time.Now().Add(-time.Hour),
		NotAfter:     time.Now().Add(30 * 24 * time.Hour),
		KeyUsage:     x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
	}
	der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
	if err != nil {
		return tls.Certificate{}, err
	}
	return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil
}

Check it:

$ go run ./httpsserver -addr 127.0.0.1:9443 -redirect 127.0.0.1:9480 &
$ curl -sk -D - https://127.0.0.1:9443/
HTTP/2 200
content-security-policy: default-src 'self'; frame-ancestors 'none'; base-uri 'none'
permissions-policy: camera=(), microphone=(), geolocation=()
referrer-policy: strict-origin-when-cross-origin
strict-transport-security: max-age=63072000; includeSubDomains
x-content-type-options: nosniff
...
hello over HTTP/2.0 (TLS 1.3, ALPN h2)

$ curl -s -o /dev/null -w '%{http_code} -> %{redirect_url}\n' 'http://127.0.0.1:9480/x?y=1'
301 -> https://127.0.0.1:9443/x?y=1

$ kill -TERM %1
{"level":"INFO","msg":"draining"}
{"level":"INFO","msg":"stopped"}

What to notice:

  • HTTP/2 came for free. ListenAndServeTLS negotiates h2 via ALPN automatically. No code was needed.
  • The GET /{$} pattern (Go 1.22+ routing) matches exactly /, so unknown paths get a 404 instead of falling through to the home page.
  • Liveness vs readiness: /healthz always says "the process is alive"; /readyz fails during draining so the load balancer stops sending traffic (TCP/IP guide, Chapter 57).
  • ConnState counts open connections, which is the number Slowloris inflates.
  • Certificates: the lab generates a self-signed one. In production use an ACME client, or golang.org/x/crypto/acme/autocert, which plugs into TLSConfig.GetCertificate and renews automatically (Chapter 16).

Build it in Go (20 min) — Slowloris, safely

slowloris opens many connections and sends request headers one line every 3 seconds, never finishing. It refuses any target that isn't a loopback address: it's for demonstrating your own server's defences, nothing else.

// slowloris: a LOCAL-ONLY demonstration of why HTTP servers need timeouts.
// It opens many connections and sends request headers one slow line at a
// time, never finishing, so a server without a header timeout keeps every
// connection (and its goroutine/thread/memory) open indefinitely.
//
// It refuses to target anything but a loopback address. Use it only against
// servers you run yourself (Chapter 19's httpsserver).
//
//	go run ./slowloris -target 127.0.0.1:8080 -conns 200 -for 30s
package main

import (
	"flag"
	"fmt"
	"net"
	"os"
	"sync"
	"sync/atomic"
	"time"
)

func main() {
	target := flag.String("target", "127.0.0.1:8080", "loopback host:port of YOUR test server")
	conns := flag.Int("conns", 200, "connections to open")
	dur := flag.Duration("for", 30*time.Second, "how long to keep trying")
	every := flag.Duration("every", 3*time.Second, "delay between header lines")
	flag.Parse()

	host, _, err := net.SplitHostPort(*target)
	if ip := net.ParseIP(host); err != nil || ip == nil || !ip.IsLoopback() {
		fmt.Println("refusing: -target must be a loopback IP (127.0.0.1 or ::1) of a server you run")
		os.Exit(2)
	}

	var alive atomic.Int64
	var wg sync.WaitGroup
	deadline := time.Now().Add(*dur)
	for i := 0; i < *conns; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c, err := net.DialTimeout("tcp", *target, 3*time.Second)
			if err != nil {
				return
			}
			defer c.Close()
			alive.Add(1)
			defer alive.Add(-1)
			fmt.Fprintf(c, "GET / HTTP/1.1\r\nHost: %s\r\n", host) // no blank line: never finished
			for n := 0; time.Now().Before(deadline); n++ {
				time.Sleep(*every)
				if _, err := fmt.Fprintf(c, "X-Slow-%d: keep-waiting\r\n", n); err != nil {
					return // the server hung up on us: its timeout worked
				}
			}
		}()
	}
	for t := time.Now(); time.Now().Before(deadline); time.Sleep(5 * time.Second) {
		fmt.Printf("t=%2.0fs  connections still held open by the server: %d/%d\n",
			time.Since(t).Seconds(), alive.Load(), *conns)
	}
	wg.Wait()
}

Run it against the server with timeouts disabled, then with them enabled:

$ go run ./httpsserver -plain 127.0.0.1:9481 -no-timeouts &
$ go run ./slowloris -target 127.0.0.1:9481 -conns 200 -for 16s
t= 5s  connections still held open by the server: 200/200
t=10s  connections still held open by the server: 200/200
t=15s  connections still held open by the server: 200/200

$ go run ./httpsserver -plain 127.0.0.1:9482 &        # ReadHeaderTimeout = 5s
$ go run ./slowloris -target 127.0.0.1:9482 -conns 200 -for 16s
t= 5s  connections still held open by the server: 200/200
t=10s  connections still held open by the server: 0/200
t=15s  connections still held open by the server: 0/200

With no header timeout, 200 connections from one process are held forever, and 200,000 from a botnet would exhaust memory or file descriptors. With ReadHeaderTimeout: 5s, every one is closed within the next few seconds. Note that the trickle never stopped: each connection was still sending. A timeout per write wouldn't catch it. The deadline has to cover the whole header phase.

Real-world scenario

A team runs a Go API directly on the internet with http.ListenAndServe. During a minor DDoS, connections climb into the hundreds of thousands while request rate stays flat, and the process is OOM-killed. Nothing in the access logs explains it, because no request ever completed. The fix is four fields on http.Server, plus a reverse proxy or CDN in front that absorbs slow clients before they reach the app.

Common mistakes

  • Using http.ListenAndServe (no timeouts) in production code.
  • Setting WriteTimeout and then wondering why streaming endpoints and large downloads get cut off (Chapter 24).
  • Adding security headers in some handlers but not others. Use middleware.
  • Exiting immediately on SIGTERM instead of draining.
  • Hand-tuning cipher suites from an old blog post, which locks in weaker choices than Go's maintained defaults.

Check yourself

  1. Which http.Server field stops Slowloris, and why doesn't WriteTimeout?
  2. Why is HSTS only set when r.TLS != nil?
  3. What's the difference between /healthz and /readyz during a shutdown?
  4. Why should IdleTimeout be longer than the load balancer's idle timeout?

Key Takeaways

  • Set all four timeouts and MaxHeaderBytes on every http.Server.
  • Put security headers in middleware. Leave TLS cipher choices to Go's defaults.
  • Fail readiness, wait, drain, then exit.

Across the series: Go guide, Chapter 62 (production HTTP APIs) and Chapter 57 (shipping: Docker, systemd, graceful shutdown); Go plan Day 62 (hardened server) and Day 107 (multi-stage graceful shutdown); Linux guide, Chapter 20 for how SIGTERM reaches your process.

Generated from real-life-guide-v1.md · 8 Oct 2026 16:36