Fix blank page: add Cloudflare-fronted HTTP-only Caddyfile #4

Merged
ahmza merged 3 commits from fix-caddy-cloudflare into main 2026-07-25 03:56:08 +00:00
2 changed files with 155 additions and 8 deletions
+74
View File
@@ -0,0 +1,74 @@
# 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.
#
# Ranges from https://www.cloudflare.com/ips-v4/ and /ips-v6/ .
# Keep in sync when Cloudflare updates them (rarely). If you rebuild
# caddy-cf with the caddy-dynamic-cloudflare / caddy-cloudflare-ip plugin,
# this static list can be replaced with `trusted_proxies cloudflare`.
servers {
trusted_proxies static \
173.245.48.0/20 \
103.21.244.0/22 \
103.22.200.0/22 \
103.31.4.0/22 \
141.101.64.0/18 \
108.162.192.0/18 \
190.93.240.0/20 \
188.114.96.0/20 \
197.234.240.0/22 \
198.41.128.0/17 \
162.158.0.0/15 \
104.16.0.0/13 \
104.24.0.0/14 \
172.64.0.0/13 \
131.0.72.0/22 \
2400:cb00::/32 \
2606:4700::/32 \
2803:f800::/32 \
2405:b500::/32 \
2405:8100::/32 \
2a06:98c0::/29 \
2c0f:f248::/32
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
}
}
+81 -8
View File
@@ -1,15 +1,44 @@
# Caddy as a Reverse Proxy # Caddy as a Reverse Proxy
This document describes how to run [Caddy](https://caddyserver.com/) as a 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 reverse proxy in front of the blog.
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 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 (`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** module** baked in.
challenge for Cloudflare; this custom build can, which is what lets us obtain
certificates without exposing ports `80`/`443` to the public internet. ## 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? ## Why a reverse proxy?
@@ -21,7 +50,7 @@ the reverse proxy so that each layer owns exactly one job:
| Concern | Owned by | Notes | | 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 | | 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` |
@@ -50,11 +79,55 @@ 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 (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. 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 static <Cloudflare ranges>`** + **`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 ranges come from
<https://www.cloudflare.com/ips-v4/> and <https://www.cloudflare.com/ips-v6/>;
keep them in sync when Cloudflare updates them. If `caddy-cf` is rebuilt with
the `caddy-cloudflare-ip` plugin, the static list can be replaced with
`trusted_proxies cloudflare`.
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) ## Docker Compose (Gimli)
Run Caddy and the blog container on the same Docker network. Caddy reaches the Run Caddy and the blog container on the same Docker network. Caddy reaches the