Files
blog-ahmza-com/docs/caddy-reverse-proxy.md
T
ahmza 2c6f20fbc3 docs: show dynamic cloudflare trusted_proxies as primary example
caddy-cf now bundles WeidiDeng/caddy-cloudflare-ip, so document
'trusted_proxies cloudflare { interval 12h; timeout 15s }' as the
primary (auto-refreshing) approach, with the static-list method noted
as the fallback for builds without the plugin. Also explains
client_ip_headers (Cf-Connecting-Ip preferred, X-Forwarded-For fallback).
2026-07-25 05:04:52 +01:00

14 KiB

Caddy as a Reverse Proxy

This document describes how to run Caddy as a reverse proxy in front of the blog.

It uses the custom caddy-cf image (ghcr.io/ahmzaa/caddy-cf:latest) — a Caddy build with the Cloudflare DNS module baked in.

Deployment topologies

There are two supported ways to run Caddy in front of the blog. Pick one.

1. Cloudflare in front (current production) — HTTP only

Cloudflare (terminates TLS)  ->  Caddy (HTTP :80)  ->  SWS (blog:80)

Cloudflare proxies the domain and provides the public HTTPS certificate, so Caddy only needs to serve plain HTTP on :80 and reverse-proxy to the blog. Caddy does not provision a TLS cert in this mode. This is the live setup and its config is the committed Caddyfile at the repo root — see Cloudflare-fronted setup below.

Set the Cloudflare SSL/TLS mode to "Full" so the Cloudflare→origin hop is still encrypted at the network layer (e.g. over Netbird), even though Caddy's listener is HTTP.

2. Caddy terminates TLS via Cloudflare DNS-01

Client  ->  Caddy (HTTPS :443, ACME DNS-01)  ->  SWS (blog:80)

Caddy obtains/renews the certificate itself using the ACME DNS-01 challenge through the Cloudflare API — no inbound 80/443 from the public internet is required, which suits a Netbird-only setup with no Cloudflare proxy in front. The stock Caddy image cannot do this; the caddy-cf build bundles the Cloudflare DNS provider plugin. See the Docker Compose / Kubernetes examples later in this document.

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 Cloudflare or Caddy Cloudflare in topology 1; Caddy (DNS-01) in topology 2
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 handle TLS (or let Cloudflare do it), add compression + security headers, and forward requests to that origin.


Cloudflare-fronted setup (HTTP only)

This is the current production topology. The canonical config is the committed Caddyfile at the repo root; copy it to the caddy_cf host and reload. Highlights:

  • http://blog.ahmza.com — the explicit http:// scheme binds Caddy to :80 only and stops it from trying to provision a TLS certificate (Cloudflare already serves HTTPS to visitors).

  • reverse_proxy blog:80 — proxies to the blog (SWS) container by its compose service name. Caddy and the blog container must share a Docker network. Omitting this responder makes Caddy return an empty NOP response — i.e. a blank page.

  • encode zstd gzip — compression happens here (SWS has it disabled).

  • header { ... } — security headers (SWS has them disabled).

  • trusted_proxies cloudflare { ... } + client_ip_headers Cf-Connecting-Ip X-Forwarded-For — so the real visitor IP is used for logging/matchers, but only when the request genuinely comes from Cloudflare (prevents header spoofing from any other source). The cloudflare IP source (WeidiDeng/caddy-cloudflare-ip, baked into the caddy-cf image) auto-fetches the ranges from https://www.cloudflare.com/ips-v4/ and https://www.cloudflare.com/ips-v6/ and refreshes them on the configured interval, so there is no static list to maintain:

    trusted_proxies cloudflare {
    	interval 12h
    	timeout 15s
    }
    

    client_ip_headers lists which header holds the real visitor IP: Cf-Connecting-Ip is Cloudflare's single authoritative end-user IP, with X-Forwarded-For as the fallback. It is only trusted for requests coming from the trusted_proxies above.

    If you ever run without the caddy-cloudflare-ip plugin, you can instead pin the static ranges with trusted_proxies static <ip/cidr ...> copied from the Cloudflare IP pages, but then you must keep them in sync manually.

Deploy and reload:

# Put the Caddyfile in place on the caddy_cf host (e.g. into the ./conf mount),
# then reload without downtime:
docker exec caddy_cf caddy reload --config /etc/caddy/Caddyfile

# Verify from the host (expect HTTP 200, Content-Encoding: gzip, X-Frame-Options):
curl -sI -H "Accept-Encoding: gzip" http://localhost/ | grep -iE 'HTTP|content-encoding|x-frame'

Cloudflare SSL/TLS mode: set it to Full so the Cloudflare→origin hop is encrypted at the network layer (e.g. over Netbird), even though Caddy's listener is plain HTTP.


The remaining sections describe topology 2 (Caddy terminates TLS itself via the Cloudflare DNS-01 challenge) for setups without a Cloudflare proxy in front.

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 ./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.

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 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 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

  • 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 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.