From 7d4f1668a6e93b405d9c1d24e16aed399fe75d1d Mon Sep 17 00:00:00 2001 From: ahmzaa Date: Sat, 25 Jul 2026 01:13:10 +0100 Subject: [PATCH] Add Caddy reverse proxy documentation Document running Caddy in front of the SWS origin for both deployment targets (Docker Compose on Gimli and the Kubernetes Pi cluster), with Caddy handling automatic HTTPS, compression, and security headers. Explains the layer separation: SWS serves static files + Cache-Control, Caddy owns TLS/compression/security headers (matching sws.toml where those are disabled). Caddyfile validated with 'caddy validate'. --- docs/caddy-reverse-proxy.md | 258 ++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/caddy-reverse-proxy.md 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.