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.
ListenAndServeTLSnegotiatesh2via 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:
/healthzalways says "the process is alive";/readyzfails during draining so the load balancer stops sending traffic (TCP/IP guide, Chapter 57). ConnStatecounts 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 intoTLSConfig.GetCertificateand 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
WriteTimeoutand 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
- Which
http.Serverfield stops Slowloris, and why doesn'tWriteTimeout? - Why is HSTS only set when
r.TLS != nil? - What's the difference between
/healthzand/readyzduring a shutdown? - Why should
IdleTimeoutbe longer than the load balancer's idle timeout?
Key Takeaways
- Set all four timeouts and
MaxHeaderByteson everyhttp.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.