# 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.