diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..b3d2a17 --- /dev/null +++ b/Caddyfile @@ -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 + } +} diff --git a/docs/caddy-reverse-proxy.md b/docs/caddy-reverse-proxy.md index a9d7dea..da7fe97 100644 --- a/docs/caddy-reverse-proxy.md +++ b/docs/caddy-reverse-proxy.md @@ -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,55 @@ 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 static `** + **`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 + and ; + 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) Run Caddy and the blog container on the same Docker network. Caddy reaches the