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)
10 KiB
Caddy as a Reverse Proxy
This document describes how to run Caddy 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 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),
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 (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:
# .env
CF_API_TOKEN=your-cloudflare-api-token-with-zone-dns-edit
compose.yml
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
# 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
./datavolume 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:
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):
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
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
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
blogService should expose port80and target the SWS container port80. Caddy reaches it asblog:80from inside the same namespace.
Certificate persistence: the
caddy-dataPVC is the K8s equivalent of the./dataDocker 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/443from the public internet for ACME. TheLoadBalancer/NodePortabove 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-cfimage solves the ACME challenge via the Cloudflare API (DNS-01), Caddy does not need ports80/443exposed 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.comzone. Keep it in a.envfile (Docker) or aSecret(K8s), never in the committed Caddyfile — the config references it as{env.CF_API_TOKEN}. - DNS must be managed by Cloudflare. The
ahmza.comzone has to be hosted on Cloudflare for the DNS-01 challenge to create the required_acme-challengeTXT record. - Do not double-compress. SWS compression is off by design; Caddy's
encodedirective is the only place compression happens. Enabling both wastes CPU. - Do not duplicate headers. Security headers are set in the
Caddyfile. SWS hassecurity-headers = falseso 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-Controlheader directives in theCaddyfile. - Health checks. Both examples use SWS's
/healthendpoint 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/udpto enable QUIC. Remove it if your network path does not support UDP.