diff --git a/docs/caddy-reverse-proxy.md b/docs/caddy-reverse-proxy.md index 0045f1c..a9d7dea 100644 --- a/docs/caddy-reverse-proxy.md +++ b/docs/caddy-reverse-proxy.md @@ -5,6 +5,12 @@ 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/), @@ -15,15 +21,25 @@ the reverse proxy so that each layer owns exactly one job: | Concern | Owned by | Notes | | -------------------- | -------- | ------------------------------------------------- | -| TLS / HTTPS certs | Caddy | Automatic Let's Encrypt certificates | +| 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 | -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. +### 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 @@ -34,8 +50,8 @@ Whatever the deployment, the SWS origin exposes: - **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. +Caddy only needs to terminate TLS (via Cloudflare DNS-01), add compression + +security headers, and forward requests to that origin. --- @@ -45,6 +61,15 @@ 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 @@ -58,26 +83,39 @@ services: - "80" caddy: - image: caddy:2-alpine + 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: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - - caddy_data:/data # persists certificates — DO NOT lose this - - caddy_config:/config - -volumes: - caddy_data: - caddy_config: + - ./conf:/etc/caddy # contains the Caddyfile + - ./data:/data # persists certificates — DO NOT lose this + - ./config:/config ``` -### `Caddyfile` +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. @@ -106,7 +144,7 @@ blog.ahmza.com { } ``` -> **Certificate persistence:** the `caddy_data` volume stores the issued +> **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. @@ -125,6 +163,18 @@ The blog runs as a `Deployment` behind a `Service`. Caddy runs as its own (`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 @@ -135,7 +185,15 @@ metadata: 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 @@ -179,12 +237,18 @@ spec: spec: containers: - name: caddy - image: caddy:2-alpine + 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 @@ -232,18 +296,29 @@ spec: > 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 +> `./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 -- **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). +- **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