Files
blog-ahmza-com/docs/caddy-reverse-proxy.md
T
ahmza 7d4f1668a6 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'.
2026-07-25 01:13:10 +01:00

7.4 KiB

Caddy as a Reverse Proxy

This document describes how to run Caddy 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), 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

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

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:

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

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

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.