259 lines
7.4 KiB
Markdown
259 lines
7.4 KiB
Markdown
# 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).
|
||
|
|
|
||
|
|
## Why a reverse proxy?
|
||
|
|
|
||
|
|
The blog is served by [Static Web Server (SWS)](https://static-web-server.net/),
|
||
|
|
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 | Caddy | Automatic Let's Encrypt certificates |
|
||
|
|
| 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 |
|
||
|
|
|
||
|
|
Caddy is a good fit here because it does **automatic HTTPS** out of the box:
|
||
|
|
point it at a domain, and it obtains and renews Let's Encrypt certificates with
|
||
|
|
no manual configuration.
|
||
|
|
|
||
|
|
## 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 terminate TLS, add compression + security headers, and
|
||
|
|
forward requests to that origin.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 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.
|
||
|
|
|
||
|
|
### `compose.yml`
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
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: caddy:2-alpine
|
||
|
|
restart: unless-stopped
|
||
|
|
ports:
|
||
|
|
- "80:80"
|
||
|
|
- "443:443"
|
||
|
|
- "443:443/udp" # HTTP/3 (QUIC)
|
||
|
|
volumes:
|
||
|
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||
|
|
- caddy_data:/data # persists certificates — DO NOT lose this
|
||
|
|
- caddy_config:/config
|
||
|
|
|
||
|
|
volumes:
|
||
|
|
caddy_data:
|
||
|
|
caddy_config:
|
||
|
|
```
|
||
|
|
|
||
|
|
### `Caddyfile`
|
||
|
|
|
||
|
|
```caddy
|
||
|
|
blog.ahmza.com {
|
||
|
|
# 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 `caddy_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:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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).
|
||
|
|
|
||
|
|
### Caddyfile via ConfigMap
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
apiVersion: v1
|
||
|
|
kind: ConfigMap
|
||
|
|
metadata:
|
||
|
|
name: caddy-config
|
||
|
|
namespace: blog-ahmza-com
|
||
|
|
data:
|
||
|
|
Caddyfile: |
|
||
|
|
blog.ahmza.com {
|
||
|
|
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
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
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: caddy:2-alpine
|
||
|
|
ports:
|
||
|
|
- containerPort: 80
|
||
|
|
- containerPort: 443
|
||
|
|
- containerPort: 443
|
||
|
|
protocol: UDP
|
||
|
|
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
|
||
|
|
> `caddy_data` Docker volume. Without a persistent volume, a pod restart wipes
|
||
|
|
> the certificates and re-triggers ACME issuance.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Notes and gotchas
|
||
|
|
|
||
|
|
- **Automatic HTTPS requires reachability.** For Caddy to obtain a certificate,
|
||
|
|
`blog.ahmza.com` must resolve to Caddy's public IP and ports `80`/`443` must be
|
||
|
|
reachable from the internet (HTTP-01 / TLS-ALPN-01 challenge). If the cluster
|
||
|
|
is only reachable over Netbird, use the DNS-01 challenge instead (requires a
|
||
|
|
Caddy build with your DNS provider plugin and an API token).
|
||
|
|
- **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.
|