diff --git a/Dockerfile b/Dockerfile index e06e287..62c0cea 100755 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,9 @@ WORKDIR /tmp/hugo COPY . . RUN hugo --gc --minify -FROM nginx:alpine-slim -WORKDIR /usr/share/nginx/html -COPY nginx.conf /etc/nginx/nginx.conf -COPY --from=build /tmp/hugo/public . +FROM ghcr.io/static-web-server/static-web-server:2 +COPY sws.toml /etc/sws.toml +COPY --from=build /tmp/hugo/public /public +ENV SERVER_CONFIG_FILE=/etc/sws.toml EXPOSE 80/tcp +CMD ["--config-file", "/etc/sws.toml"] diff --git a/docs/caddy-reverse-proxy.md b/docs/caddy-reverse-proxy.md new file mode 100644 index 0000000..a9d7dea --- /dev/null +++ b/docs/caddy-reverse-proxy.md @@ -0,0 +1,333 @@ +# 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). + +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. + +## 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 certs via Cloudflare **DNS-01** challenge | +| 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 | + +### Why the Cloudflare DNS-01 challenge? + +Caddy does **automatic HTTPS** out of the box, but the default HTTP-01 / +TLS-ALPN-01 challenges require ports `80`/`443` to be publicly reachable so +Let's Encrypt can verify domain ownership. This project's infrastructure is +reached over [Netbird](https://netbird.io/) (a private overlay network), so +those inbound challenges are not an option. + +The **DNS-01** challenge instead proves domain ownership by creating a TXT +record via the Cloudflare API — no inbound connectivity required. The `caddy-cf` +image bundles the Cloudflare DNS provider plugin so Caddy can do this. All it +needs is a Cloudflare API token with **Zone:DNS:Edit** permission on the +`ahmza.com` zone. + +## 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 (via Cloudflare DNS-01), 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. + +The Cloudflare API token is passed to Caddy as the `CF_API_TOKEN` environment +variable and referenced from the Caddyfile — keep it out of the committed +config. Store it in a `.env` file (git-ignored) next to `compose.yml`: + +```dotenv +# .env +CF_API_TOKEN=your-cloudflare-api-token-with-zone-dns-edit +``` + +### `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: ghcr.io/ahmzaa/caddy-cf:latest # Caddy + Cloudflare DNS module + restart: unless-stopped + container_name: caddy-cf + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 (QUIC) + environment: + # Consumed by the {env.CF_API_TOKEN} placeholder in the Caddyfile. + - CF_API_TOKEN=${CF_API_TOKEN} + volumes: + - ./conf:/etc/caddy # contains the Caddyfile + - ./data:/data # persists certificates — DO NOT lose this + - ./config:/config +``` + +Place the Caddyfile at `./conf/Caddyfile`. + +### `./conf/Caddyfile` + +```caddy +# Reusable Cloudflare DNS-01 TLS snippet. The token is read from the +# CF_API_TOKEN environment variable so it never lives in the config file. +(cloudflare) { + tls { + dns cloudflare {env.CF_API_TOKEN} + } +} + +blog.ahmza.com { + # Obtain/renew the cert via the Cloudflare DNS-01 challenge. + import cloudflare + + # 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 `./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). + +### Cloudflare API token Secret + +Store the Cloudflare API token in a `Secret` and inject it into the Caddy +container as the `CF_API_TOKEN` environment variable (never bake it into the +ConfigMap): + +```bash +kubectl create secret generic caddy-cloudflare \ + --namespace blog-ahmza-com \ + --from-literal=CF_API_TOKEN='your-cloudflare-api-token-with-zone-dns-edit' +``` + +### Caddyfile via ConfigMap + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: caddy-config + namespace: blog-ahmza-com +data: + Caddyfile: | + (cloudflare) { + tls { + dns cloudflare {env.CF_API_TOKEN} + } + } + + blog.ahmza.com { + import cloudflare + + 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: ghcr.io/ahmzaa/caddy-cf:latest # Caddy + Cloudflare DNS module + ports: + - containerPort: 80 + - containerPort: 443 + - containerPort: 443 + protocol: UDP + env: + - name: CF_API_TOKEN + valueFrom: + secretKeyRef: + name: caddy-cloudflare + key: CF_API_TOKEN + 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 +> `./data` Docker volume. Without a persistent volume, a pod restart wipes +> the certificates and re-triggers ACME issuance. + +> **DNS-01 and Netbird:** because certificates are issued via the Cloudflare +> DNS-01 challenge, the cluster does **not** need inbound `80`/`443` from the +> public internet for ACME. The `LoadBalancer`/`NodePort` above only needs to be +> reachable by your clients (e.g. over Netbird or your LAN). + +--- + +## Notes and gotchas + +- **DNS-01 needs no inbound reachability.** Because the `caddy-cf` image solves + the ACME challenge via the Cloudflare API (DNS-01), Caddy does **not** need + ports `80`/`443` exposed to the public internet to issue certificates. This is + what makes it work behind Netbird. It does need outbound HTTPS to the Let's + Encrypt and Cloudflare APIs. +- **Cloudflare API token scope.** The token needs **Zone → DNS → Edit** on the + `ahmza.com` zone. Keep it in a `.env` file (Docker) or a `Secret` (K8s), never + in the committed Caddyfile — the config references it as `{env.CF_API_TOKEN}`. +- **DNS must be managed by Cloudflare.** The `ahmza.com` zone has to be hosted on + Cloudflare for the DNS-01 challenge to create the required `_acme-challenge` + TXT record. +- **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. diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 95b3cf2..0000000 --- a/nginx.conf +++ /dev/null @@ -1,70 +0,0 @@ -# auto detects a good number of processes to run -worker_processes auto; - -#Provides the configuration file context in which the directives that affect connection processing are specified. -events { - # Sets the maximum number of simultaneous connections that can be opened by a worker process. - worker_connections 1024; - # Tells the worker to accept multiple connections at a time - multi_accept on; -} - - -http { - # what times to include - include /etc/nginx/mime.types; - # what is the default one - default_type application/octet-stream; - - # Sets the path, format, and configuration for a buffered log write - log_format compression '$remote_addr - $remote_user [$time_local] ' - '"$request" $status $upstream_addr ' - '"$http_referer" "$http_user_agent"'; - - gzip on; - gzip_disable "MSIE [1-6]\.(?!.*SV1)"; - gzip_vary on; - gzip_proxied any; - gzip_comp_level 9; - gzip_buffers 10 8k; - gzip_http_version 1.1; - gzip_types text/plain text/css text/xml text/javascript image/webp image/svg+xml image/x-icon application/javascript application/x-javascript application/json application/xml application/xml+rss; - - server { - # listen on port 80 - listen 80; - # save logs here - access_log /var/log/nginx/access.log compression; - - # where's the root dir - root /usr/share/nginx/html; - # what file to serve as index - index index.html index.htm; - - location / { - # First attempt to serve request as file, then - # as directory, then fall back to redirecting to index.html - try_files $uri $uri/ /index.html; - } - - # Media: images, icons, video, audio, HTC - location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc|webp)$ { - expires 1y; - access_log off; - add_header Cache-Control "public"; - } - - # Javascript and CSS files - location ~* \.(?:css|js)$ { - try_files $uri =404; - expires 1y; - access_log off; - add_header Cache-Control "public"; - } - - # Any route containing a file extension (e.g. /devicesfile.js) - location ~ ^.+\..+$ { - try_files $uri =404; - } - } -} diff --git a/sws.toml b/sws.toml new file mode 100644 index 0000000..61798ec --- /dev/null +++ b/sws.toml @@ -0,0 +1,46 @@ +[general] + +#### Server + +# Listen on all interfaces, port 80 (matches previous nginx setup) +host = "0.0.0.0" +port = 80 + +# Root directory of the static site (Hugo `public/` output) +root = "/public" + +# Number of worker threads (0 = auto-detect based on CPU cores) +threads-multiplier = 1 + +# Log level +log-level = "info" + +#### Performance / caching + +# Compression is handled by the upstream reverse proxy, so it is disabled +# here to avoid doing the work twice (wasted CPU per request). Re-enable if +# SWS is ever exposed directly without a compressing proxy in front. +compression = false +compression-static = false + +# Emit Cache-Control + ETag headers with sensible per-content-type defaults. +# The origin is the correct place to declare cacheability; the proxy/CDN +# passes these through. SWS applies long-lived caching to static assets +# (images, css, js, fonts) and short/no-cache to HTML. +cache-control-headers = true + +#### Routing + +# SPA-style fallback: serve index.html when a path is not found on disk, +# mirroring the previous nginx `try_files $uri $uri/ /index.html`. +page-fallback = "/public/index.html" + +#### Security + +# Security headers are set by the upstream reverse proxy, so they are +# disabled here to avoid duplicate/conflicting headers. Let one layer own +# the security posture. Re-enable if SWS is exposed without such a proxy. +security-headers = false + +# Health endpoint at /health for container/K8s liveness probes +health = true