# 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). It uses the custom [`caddy-cf`](https://github.com/ahmzaa/caddy-cf) image (`ghcr.io/ahmzaa/caddy-cf:latest`) — a Caddy build with the **Cloudflare DNS module** baked in. The stock Caddy image cannot solve the ACME **DNS-01** challenge for Cloudflare; this custom build can, which is what lets us obtain certificates without exposing ports `80`/`443` to the public internet. ## 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 certs via Cloudflare **DNS-01** challenge | | 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 | ### Why the Cloudflare DNS-01 challenge? Caddy does **automatic HTTPS** out of the box, but the default HTTP-01 / TLS-ALPN-01 challenges require ports `80`/`443` to be publicly reachable so Let's Encrypt can verify domain ownership. This project's infrastructure is reached over [Netbird](https://netbird.io/) (a private overlay network), so those inbound challenges are not an option. The **DNS-01** challenge instead proves domain ownership by creating a TXT record via the Cloudflare API — no inbound connectivity required. The `caddy-cf` image bundles the Cloudflare DNS provider plugin so Caddy can do this. All it needs is a Cloudflare API token with **Zone:DNS:Edit** permission on the `ahmza.com` zone. ## 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 (via Cloudflare DNS-01), 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. The Cloudflare API token is passed to Caddy as the `CF_API_TOKEN` environment variable and referenced from the Caddyfile — keep it out of the committed config. Store it in a `.env` file (git-ignored) next to `compose.yml`: ```dotenv # .env CF_API_TOKEN=your-cloudflare-api-token-with-zone-dns-edit ``` ### `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: ghcr.io/ahmzaa/caddy-cf:latest # Caddy + Cloudflare DNS module restart: unless-stopped container_name: caddy-cf ports: - "80:80" - "443:443" - "443:443/udp" # HTTP/3 (QUIC) environment: # Consumed by the {env.CF_API_TOKEN} placeholder in the Caddyfile. - CF_API_TOKEN=${CF_API_TOKEN} volumes: - ./conf:/etc/caddy # contains the Caddyfile - ./data:/data # persists certificates — DO NOT lose this - ./config:/config ``` Place the Caddyfile at `./conf/Caddyfile`. ### `./conf/Caddyfile` ```caddy # Reusable Cloudflare DNS-01 TLS snippet. The token is read from the # CF_API_TOKEN environment variable so it never lives in the config file. (cloudflare) { tls { dns cloudflare {env.CF_API_TOKEN} } } blog.ahmza.com { # Obtain/renew the cert via the Cloudflare DNS-01 challenge. import cloudflare # 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 `./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). ### Cloudflare API token Secret Store the Cloudflare API token in a `Secret` and inject it into the Caddy container as the `CF_API_TOKEN` environment variable (never bake it into the ConfigMap): ```bash kubectl create secret generic caddy-cloudflare \ --namespace blog-ahmza-com \ --from-literal=CF_API_TOKEN='your-cloudflare-api-token-with-zone-dns-edit' ``` ### Caddyfile via ConfigMap ```yaml apiVersion: v1 kind: ConfigMap metadata: name: caddy-config namespace: blog-ahmza-com data: Caddyfile: | (cloudflare) { tls { dns cloudflare {env.CF_API_TOKEN} } } blog.ahmza.com { import cloudflare 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: ghcr.io/ahmzaa/caddy-cf:latest # Caddy + Cloudflare DNS module ports: - containerPort: 80 - containerPort: 443 - containerPort: 443 protocol: UDP env: - name: CF_API_TOKEN valueFrom: secretKeyRef: name: caddy-cloudflare key: CF_API_TOKEN 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 > `./data` Docker volume. Without a persistent volume, a pod restart wipes > the certificates and re-triggers ACME issuance. > **DNS-01 and Netbird:** because certificates are issued via the Cloudflare > DNS-01 challenge, the cluster does **not** need inbound `80`/`443` from the > public internet for ACME. The `LoadBalancer`/`NodePort` above only needs to be > reachable by your clients (e.g. over Netbird or your LAN). --- ## Notes and gotchas - **DNS-01 needs no inbound reachability.** Because the `caddy-cf` image solves the ACME challenge via the Cloudflare API (DNS-01), Caddy does **not** need ports `80`/`443` exposed to the public internet to issue certificates. This is what makes it work behind Netbird. It does need outbound HTTPS to the Let's Encrypt and Cloudflare APIs. - **Cloudflare API token scope.** The token needs **Zone → DNS → Edit** on the `ahmza.com` zone. Keep it in a `.env` file (Docker) or a `Secret` (K8s), never in the committed Caddyfile — the config references it as `{env.CF_API_TOKEN}`. - **DNS must be managed by Cloudflare.** The `ahmza.com` zone has to be hosted on Cloudflare for the DNS-01 challenge to create the required `_acme-challenge` TXT record. - **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.