Author SHA1 Message Date
ahmza 94c561581a 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:03:12 +01:00
ahmza ce7a72728d Use dynamic cloudflare trusted_proxies IP source
caddy-cf now includes WeidiDeng/caddy-cloudflare-ip, so replace the
hardcoded 22-range static list with 'trusted_proxies cloudflare' which
auto-fetches and refreshes Cloudflare's IPv4/IPv6 ranges (interval 12h,
timeout 15s). No static list to keep in sync when Cloudflare updates.

Kept client_ip_headers Cf-Connecting-Ip X-Forwarded-For (prefer CF's
authoritative end-user IP, fall back to XFF) and documented why.

Validated + fmt-clean and tested end-to-end against the updated caddy-cf
image: starts cleanly, serves HTTP 200 with gzip + security headers.
2026-07-25 05:01:49 +01:00
ahmza c24ed01e0a docs: document Cloudflare-fronted HTTP-only Caddy topology
The live production setup is Cloudflare -> Caddy (HTTP :80) -> SWS, which
differed from the DNS-01 topology the doc previously assumed. Add a
'Deployment topologies' overview and a dedicated Cloudflare-fronted
section pointing at the committed root Caddyfile, covering reverse_proxy,
encode, security headers, and Cloudflare trusted_proxies. Label the
existing Compose/K8s examples as the DNS-01 alternative.
2026-07-25 04:29:33 +01:00
ahmza 7e1728a037 Restrict Caddy trusted_proxies to Cloudflare IP ranges
Replace 'private_ranges' with Cloudflare's official published edge ranges
(15 IPv4 + 7 IPv6 from cloudflare.com/ips-v4 and /ips-v6). This ensures
Cf-Connecting-Ip / X-Forwarded-For are only honoured for requests that
genuinely originate from Cloudflare, preventing client-IP header spoofing
from any other source.

Validated + fmt-clean and tested at runtime against the caddy-cf image:
Caddy starts cleanly with all 22 ranges, serves HTTP 200 with gzip and
security headers.
2026-07-25 04:25:25 +01:00
ahmza 30ef38b143 Add HTTP-only Caddyfile for Cloudflare-fronted deployment
The live Caddy was returning a blank page (NOP, status 0) because its
site block had no responder \u2014 no reverse_proxy to the blog. This adds a
known-good HTTP-only Caddyfile matching the actual topology
(Cloudflare terminates TLS -> Caddy :80 -> SWS blog:80):

- reverse_proxy blog:80  (the missing responder that caused the blank page)
- encode zstd gzip        (fixes: origin was serving everything uncompressed)
- security headers        (fixes: HSTS/X-Frame-Options/etc were absent)
- trusted_proxies + client_ip_headers for correct Cloudflare visitor IPs
- http:// scheme so Caddy does not try to provision TLS (Cloudflare does)

Validated + fmt-clean against the caddy-cf image; tested end-to-end
locally (Caddy -> SWS) returning HTTP 200 with gzip and headers.
2026-07-25 04:19:34 +01:00
2 changed files with 157 additions and 8 deletions
+59
View File
@@ -0,0 +1,59 @@
# Caddyfile for the blog behind Cloudflare.
#
# Topology: Cloudflare (terminates TLS) -> Caddy (HTTP :80) -> SWS (blog:80)
#
# Cloudflare handles the public HTTPS cert, so Caddy serves plain HTTP on :80
# and does NOT need the Cloudflare DNS-01 / automatic-HTTPS setup. Set the
# Cloudflare SSL/TLS mode to "Full" so CF->origin is encrypted at the network
# layer (e.g. over your tunnel/Netbird), even though this listener is HTTP.
#
# Copy this file to /etc/caddy/Caddyfile on the caddy_cf host and reload:
# docker exec caddy_cf caddy reload --config /etc/caddy/Caddyfile
# or restart the container.
{
# Trust only Cloudflare's edge IPs so real visitor IPs (Cf-Connecting-Ip /
# X-Forwarded-For) are honoured only when the request actually comes from
# Cloudflare. This 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 cloudflare.com/ips-v4 and
# /ips-v6 and refreshes them periodically, so there's no static list to
# keep in sync.
servers {
trusted_proxies cloudflare {
interval 12h
timeout 15s
}
# Read the real visitor IP from these headers (only honoured for
# requests from trusted_proxies above). Cf-Connecting-Ip is
# Cloudflare's single authoritative end-user IP; X-Forwarded-For is
# the fallback if a request ever arrives without the CF header.
client_ip_headers Cf-Connecting-Ip X-Forwarded-For
}
}
# Bind to the hostname on the HTTP port only. Because the address has an
# explicit ":80" scheme/port, Caddy will NOT try to provision a TLS cert.
http://blog.ahmza.com {
# THE responder: proxy every request to the blog (SWS) container.
# This is what was missing before (requests fell through to a NOP -> blank page).
reverse_proxy blog:80
# Compression happens here (SWS has it disabled on purpose).
encode zstd gzip
# Security headers (single source of truth; SWS has security-headers off).
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"
# Drop Caddy's default Server header.
-Server
}
log {
output stdout
format console
}
}
+98 -8
View File
@@ -1,15 +1,44 @@
# 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).
reverse proxy in front of the blog.
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.
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`](../Caddyfile) at the repo root —
see [Cloudflare-fronted setup](#cloudflare-fronted-setup-http-only) 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?
@@ -21,7 +50,7 @@ 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 |
| 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` |
@@ -50,11 +79,72 @@ 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 (via Cloudflare DNS-01), add compression +
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`](../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](https://github.com/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:
```caddy
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:
```bash
# 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