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:
+98
-23
@@ -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
|
this project: the Kubernetes (Raspberry Pi) cluster and the Docker Compose host
|
||||||
(Gimli).
|
(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?
|
## Why a reverse proxy?
|
||||||
|
|
||||||
The blog is served by [Static Web Server (SWS)](https://static-web-server.net/),
|
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 |
|
| 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 |
|
| Compression (gzip/zstd) | Caddy | `encode` directive; SWS compression is off |
|
||||||
| Security headers | Caddy | Single source of truth, avoids duplicate headers |
|
| Security headers | Caddy | Single source of truth, avoids duplicate headers |
|
||||||
| Serving static files | SWS | Reads from `/public`, sets `Cache-Control`/`ETag` |
|
| Serving static files | SWS | Reads from `/public`, sets `Cache-Control`/`ETag` |
|
||||||
| Cache-Control / ETag | SWS | Declared at the origin, passed through by Caddy |
|
| 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:
|
### Why the Cloudflare DNS-01 challenge?
|
||||||
point it at a domain, and it obtains and renews Let's Encrypt certificates with
|
|
||||||
no manual configuration.
|
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
|
## Origin contract
|
||||||
|
|
||||||
@@ -34,8 +50,8 @@ Whatever the deployment, the SWS origin exposes:
|
|||||||
- **Health endpoint:** `GET /health` → `200 OK`
|
- **Health endpoint:** `GET /health` → `200 OK`
|
||||||
- **Cache headers:** already set by SWS (Caddy should pass them through, not override)
|
- **Cache headers:** already set by SWS (Caddy should pass them through, not override)
|
||||||
|
|
||||||
Caddy only needs to terminate TLS, add compression + security headers, and
|
Caddy only needs to terminate TLS (via Cloudflare DNS-01), add compression +
|
||||||
forward requests to that origin.
|
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
|
blog by its service name (`blog`) over the internal network, so the blog's port
|
||||||
does **not** need to be published to the host.
|
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`
|
### `compose.yml`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -58,26 +83,39 @@ services:
|
|||||||
- "80"
|
- "80"
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: ghcr.io/ahmzaa/caddy-cf:latest # Caddy + Cloudflare DNS module
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
container_name: caddy-cf
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
- "443:443/udp" # HTTP/3 (QUIC)
|
- "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:
|
volumes:
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./conf:/etc/caddy # contains the Caddyfile
|
||||||
- caddy_data:/data # persists certificates — DO NOT lose this
|
- ./data:/data # persists certificates — DO NOT lose this
|
||||||
- caddy_config:/config
|
- ./config:/config
|
||||||
|
|
||||||
volumes:
|
|
||||||
caddy_data:
|
|
||||||
caddy_config:
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### `Caddyfile`
|
Place the Caddyfile at `./conf/Caddyfile`.
|
||||||
|
|
||||||
|
### `./conf/Caddyfile`
|
||||||
|
|
||||||
```caddy
|
```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 {
|
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 to the blog container by its compose service name.
|
||||||
reverse_proxy blog:80 {
|
reverse_proxy blog:80 {
|
||||||
# Use the SWS health endpoint for active upstream checks.
|
# 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
|
> certificates and ACME account key. Keep it — deleting it forces Caddy to
|
||||||
> re-request certificates and can hit Let's Encrypt rate limits.
|
> 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
|
(`blog.blog-ahmza-com.svc.cluster.local`, or just `blog` from within the same
|
||||||
namespace).
|
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
|
### Caddyfile via ConfigMap
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -135,7 +185,15 @@ metadata:
|
|||||||
namespace: blog-ahmza-com
|
namespace: blog-ahmza-com
|
||||||
data:
|
data:
|
||||||
Caddyfile: |
|
Caddyfile: |
|
||||||
|
(cloudflare) {
|
||||||
|
tls {
|
||||||
|
dns cloudflare {env.CF_API_TOKEN}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
blog.ahmza.com {
|
blog.ahmza.com {
|
||||||
|
import cloudflare
|
||||||
|
|
||||||
reverse_proxy blog:80 {
|
reverse_proxy blog:80 {
|
||||||
health_uri /health
|
health_uri /health
|
||||||
health_interval 10s
|
health_interval 10s
|
||||||
@@ -179,12 +237,18 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: caddy
|
- name: caddy
|
||||||
image: caddy:2-alpine
|
image: ghcr.io/ahmzaa/caddy-cf:latest # Caddy + Cloudflare DNS module
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 80
|
- containerPort: 80
|
||||||
- containerPort: 443
|
- containerPort: 443
|
||||||
- containerPort: 443
|
- containerPort: 443
|
||||||
protocol: UDP
|
protocol: UDP
|
||||||
|
env:
|
||||||
|
- name: CF_API_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: caddy-cloudflare
|
||||||
|
key: CF_API_TOKEN
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config
|
- name: config
|
||||||
mountPath: /etc/caddy
|
mountPath: /etc/caddy
|
||||||
@@ -232,18 +296,29 @@ spec:
|
|||||||
> the same namespace.
|
> the same namespace.
|
||||||
|
|
||||||
> **Certificate persistence:** the `caddy-data` PVC is the K8s equivalent of the
|
> **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.
|
> 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
|
## Notes and gotchas
|
||||||
|
|
||||||
- **Automatic HTTPS requires reachability.** For Caddy to obtain a certificate,
|
- **DNS-01 needs no inbound reachability.** Because the `caddy-cf` image solves
|
||||||
`blog.ahmza.com` must resolve to Caddy's public IP and ports `80`/`443` must be
|
the ACME challenge via the Cloudflare API (DNS-01), Caddy does **not** need
|
||||||
reachable from the internet (HTTP-01 / TLS-ALPN-01 challenge). If the cluster
|
ports `80`/`443` exposed to the public internet to issue certificates. This is
|
||||||
is only reachable over Netbird, use the DNS-01 challenge instead (requires a
|
what makes it work behind Netbird. It does need outbound HTTPS to the Let's
|
||||||
Caddy build with your DNS provider plugin and an API token).
|
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`
|
- **Do not double-compress.** SWS compression is off by design; Caddy's `encode`
|
||||||
directive is the only place compression happens. Enabling both wastes CPU.
|
directive is the only place compression happens. Enabling both wastes CPU.
|
||||||
- **Do not duplicate headers.** Security headers are set in the `Caddyfile`. SWS
|
- **Do not duplicate headers.** Security headers are set in the `Caddyfile`. SWS
|
||||||
|
|||||||
Reference in New Issue
Block a user