diff --git a/docs/caddy-reverse-proxy.md b/docs/caddy-reverse-proxy.md new file mode 100644 index 0000000..0045f1c --- /dev/null +++ b/docs/caddy-reverse-proxy.md @@ -0,0 +1,258 @@ +# Caddy as a Reverse Proxy + +This document describes how to run [Caddy](https://caddyserver.com/) as a +reverse proxy in front of the blog. It covers both deployment targets used by +this project: the Kubernetes (Raspberry Pi) cluster and the Docker Compose host +(Gimli). + +## Why a reverse proxy? + +The blog is served by [Static Web Server (SWS)](https://static-web-server.net/), +a tiny Rust binary that ships static files over plain HTTP on port `80`. SWS is +intentionally kept lean — it does **not** terminate TLS, and its compression and +security headers are disabled (see `sws.toml`). Those concerns are delegated to +the reverse proxy so that each layer owns exactly one job: + +| Concern | Owned by | Notes | +| -------------------- | -------- | ------------------------------------------------- | +| TLS / HTTPS certs | Caddy | Automatic Let's Encrypt certificates | +| Compression (gzip/zstd) | Caddy | `encode` directive; SWS compression is off | +| Security headers | Caddy | Single source of truth, avoids duplicate headers | +| Serving static files | SWS | Reads from `/public`, sets `Cache-Control`/`ETag` | +| Cache-Control / ETag | SWS | Declared at the origin, passed through by Caddy | + +Caddy is a good fit here because it does **automatic HTTPS** out of the box: +point it at a domain, and it obtains and renews Let's Encrypt certificates with +no manual configuration. + +## Origin contract + +Whatever the deployment, the SWS origin exposes: + +- **Protocol:** plain HTTP +- **Port:** `80` +- **Health endpoint:** `GET /health` → `200 OK` +- **Cache headers:** already set by SWS (Caddy should pass them through, not override) + +Caddy only needs to terminate TLS, add compression + security headers, and +forward requests to that origin. + +--- + +## Docker Compose (Gimli) + +Run Caddy and the blog container on the same Docker network. Caddy reaches the +blog by its service name (`blog`) over the internal network, so the blog's port +does **not** need to be published to the host. + +### `compose.yml` + +```yaml +services: + blog: + image: ghcr.io/ahmzaa/blog-ahmza-com + restart: unless-stopped + # No host port mapping needed — only Caddy talks to it, over the + # internal Docker network. Remove the previous "80:80" publish. + expose: + - "80" + + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 (QUIC) + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data # persists certificates — DO NOT lose this + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: +``` + +### `Caddyfile` + +```caddy +blog.ahmza.com { + # Reverse proxy to the blog container by its compose service name. + reverse_proxy blog:80 { + # Use the SWS health endpoint for active upstream checks. + health_uri /health + health_interval 10s + health_timeout 2s + } + + # Compression is done here, not in SWS. + encode zstd gzip + + # Security headers live here so there is a single source of truth. + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + # Remove Caddy's default Server header + -Server + } + + log { + output stdout + format console + } +} +``` + +> **Certificate persistence:** the `caddy_data` volume stores the issued +> certificates and ACME account key. Keep it — deleting it forces Caddy to +> re-request certificates and can hit Let's Encrypt rate limits. + +Bring it up with: + +```bash +docker compose up -d +``` + +--- + +## Kubernetes (Raspberry Pi cluster) + +The blog runs as a `Deployment` behind a `Service`. Caddy runs as its own +`Deployment` and proxies to that Service via in-cluster DNS +(`blog.blog-ahmza-com.svc.cluster.local`, or just `blog` from within the same +namespace). + +### Caddyfile via ConfigMap + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: caddy-config + namespace: blog-ahmza-com +data: + Caddyfile: | + blog.ahmza.com { + reverse_proxy blog:80 { + health_uri /health + health_interval 10s + health_timeout 2s + } + + encode zstd gzip + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } + + log { + output stdout + format console + } + } +``` + +### Caddy Deployment + Service + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: caddy + namespace: blog-ahmza-com +spec: + replicas: 1 + selector: + matchLabels: + app: caddy + template: + metadata: + labels: + app: caddy + spec: + containers: + - name: caddy + image: caddy:2-alpine + ports: + - containerPort: 80 + - containerPort: 443 + - containerPort: 443 + protocol: UDP + volumeMounts: + - name: config + mountPath: /etc/caddy + - name: data + mountPath: /data # certificate storage — must persist + volumes: + - name: config + configMap: + name: caddy-config + - name: data + persistentVolumeClaim: + claimName: caddy-data +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: caddy-data + namespace: blog-ahmza-com +spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 256Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: caddy + namespace: blog-ahmza-com +spec: + type: LoadBalancer # or NodePort, depending on the cluster + selector: + app: caddy + ports: + - name: http + port: 80 + targetPort: 80 + - name: https + port: 443 + targetPort: 443 +``` + +> **Blog Service:** the existing `blog` Service should expose port `80` and +> target the SWS container port `80`. Caddy reaches it as `blog:80` from inside +> the same namespace. + +> **Certificate persistence:** the `caddy-data` PVC is the K8s equivalent of the +> `caddy_data` Docker volume. Without a persistent volume, a pod restart wipes +> the certificates and re-triggers ACME issuance. + +--- + +## Notes and gotchas + +- **Automatic HTTPS requires reachability.** For Caddy to obtain a certificate, + `blog.ahmza.com` must resolve to Caddy's public IP and ports `80`/`443` must be + reachable from the internet (HTTP-01 / TLS-ALPN-01 challenge). If the cluster + is only reachable over Netbird, use the DNS-01 challenge instead (requires a + Caddy build with your DNS provider plugin and an API token). +- **Do not double-compress.** SWS compression is off by design; Caddy's `encode` + directive is the only place compression happens. Enabling both wastes CPU. +- **Do not duplicate headers.** Security headers are set in the `Caddyfile`. SWS + has `security-headers = false` so there is no conflict. +- **Cache-Control comes from the origin.** SWS already sets long-lived caching on + static assets and short/no-cache on HTML. Caddy passes these through; avoid + adding conflicting `Cache-Control` header directives in the `Caddyfile`. +- **Health checks.** Both examples use SWS's `/health` endpoint for active + upstream health checking, and it also works as a K8s liveness/readiness probe + on the blog pod itself. +- **HTTP/3.** The Docker example publishes `443/udp` to enable QUIC. Remove it if + your network path does not support UDP.