Author SHA1 Message Date
ahmza 09780d9598 Merge pull request 'Fix blank page: add Cloudflare-fronted HTTP-only Caddyfile' (#4) from fix-caddy-cloudflare into main
Build Hugo / build (push) Successful in 1m34s
Build Hugo / deploy (push) Successful in 40s
Reviewed-on: #4
2026-07-25 03:56:07 +00: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
ahmza f927bc191c Merge pull request 'Fix codebase audit findings: bugs, security, perf, content' (#3) from fix-audit-findings into main
Build Hugo / build (push) Successful in 1m36s
Build Hugo / deploy (push) Successful in 39s
Reviewed-on: #3
2026-07-25 02:38:13 +00:00
ahmza 25b6e0575a Merge pull request 'Optimize webm videos and webp images' (#2) from optimize-media into main
Build Hugo / build (push) Successful in 1m31s
Build Hugo / deploy (push) Successful in 40s
Reviewed-on: #2
2026-07-25 02:22:43 +00:00
ahmza e133b01b00 Optimize webm videos and webp images
Re-encode all webm meme clips as VP9 at CRF 38 (audio stripped; they are
muted autoplay loops). Downscale oversized images: background.webp
3394->1920 wide q80, profile.webp 1920->512 q85 (animation preserved).

Results:
- webm: 3.2MB -> 1.37MB (-56%)
- webp: 1.2MB -> 0.70MB (-42%)
- Docker image: 23.1MB -> 18.7MB (~42% smaller than the original nginx image)

All videos verified valid/playable; profile.webp animation (5 frames)
kept intact.
2026-07-25 03:20:57 +01:00
15 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
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 KiB

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 73 KiB

+81 -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,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 <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)
Run Caddy and the blog container on the same Docker network. Caddy reaches the
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.