Files

334 lines
10 KiB
Markdown
Raw Permalink Normal View History

2026-07-25 01:13:10 +01:00
# 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.
2026-07-25 01:13:10 +01:00
## 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 |
2026-07-25 01:13:10 +01:00
| 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.
2026-07-25 01:13:10 +01:00
## 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.
2026-07-25 01:13:10 +01:00
---
## 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
```
2026-07-25 01:13:10 +01:00
### `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
2026-07-25 01:13:10 +01:00
restart: unless-stopped
container_name: caddy-cf
2026-07-25 01:13:10 +01:00
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}
2026-07-25 01:13:10 +01:00
volumes:
- ./conf:/etc/caddy # contains the Caddyfile
- ./data:/data # persists certificates — DO NOT lose this
- ./config:/config
2026-07-25 01:13:10 +01:00
```
Place the Caddyfile at `./conf/Caddyfile`.
### `./conf/Caddyfile`
2026-07-25 01:13:10 +01:00
```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}
}
}
2026-07-25 01:13:10 +01:00
blog.ahmza.com {
# Obtain/renew the cert via the Cloudflare DNS-01 challenge.
import cloudflare
2026-07-25 01:13:10 +01:00
# 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
2026-07-25 01:13:10 +01:00
> 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'
```
2026-07-25 01:13:10 +01:00
### 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}
}
}
2026-07-25 01:13:10 +01:00
blog.ahmza.com {
import cloudflare
2026-07-25 01:13:10 +01:00
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
2026-07-25 01:13:10 +01:00
ports:
- containerPort: 80
- containerPort: 443
- containerPort: 443
protocol: UDP
env:
- name: CF_API_TOKEN
valueFrom:
secretKeyRef:
name: caddy-cloudflare
key: CF_API_TOKEN
2026-07-25 01:13:10 +01:00
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
2026-07-25 01:13:10 +01:00
> 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).
2026-07-25 01:13:10 +01:00
---
## 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.
2026-07-25 01:13:10 +01:00
- **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.