Use caddy-cf image with Cloudflare DNS-01 challenge

Switch the Caddy doc to the custom caddy-cf image
(ghcr.io/ahmzaa/caddy-cf) which bundles the Cloudflare DNS module, so
TLS certs are issued via the ACME DNS-01 challenge instead of HTTP-01.
This removes the need to expose ports 80/443 to the public internet,
which suits the Netbird-only infrastructure.

- Add (cloudflare) snippet + import cloudflare to both Caddyfiles
- Read CF_API_TOKEN from env (.env for Docker, Secret for K8s)
- Document token scope (Zone:DNS:Edit) and Cloudflare-hosted DNS
  requirement
- Validated Caddyfile against the caddy-cf image (caddy adapt)
This commit is contained in:
2026-07-25 01:17:35 +01:00
parent 7d4f1668a6
commit 1ad79d40d8
+98 -23
View File
@@ -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