Redesign page header and replace nginx with Caddy

Authorfritjof@alokat.org
Date3 weeks ago
Hashebfae720dc00959da239021353e1012c0021c41a

Summary

M ./Caddyfile -8 +9
M ./README.md -32 +11
M ./docker-compose.yml -14 +8
R ./nginx.conf
M ./src/DarcsWeb/Html.hs -2 +4
M ./static/style.css -7 +50

Diff

patch ebfae720dc00959da239021353e1012c0021c41a
Author: fritjof@alokat.org
Date:   Mon Apr 20 12:11:07 UTC 2026
  * Redesign page header and replace nginx with Caddy
hunk ./Caddyfile 10
-# Or with docker compose (replace the nginx service):
-#   caddy:
-#     image: caddy:2-alpine
-#     ports: ["80:80", "443:443", "443:443/udp"]
-#     volumes:
-#       - ./Caddyfile:/etc/caddy/Caddyfile:ro
-#       - caddy_data:/data
-#       - caddy_config:/config
+# Or via the bundled docker-compose.yml:
+#   docker compose up -d
+#
+# Note: the `rate_limit` directive below requires the caddy-ratelimit
+# module. The stock `caddy:2-alpine` image does not include it -- either
+# build a custom image (`FROM caddy:builder` then `xcaddy build --with
+# github.com/mholt/caddy-ratelimit`), use a prebuilt image that bundles
+# the module, or remove the `rate_limit { ... }` block to run with the
+# stock image.
hunk ./README.md 228
-The included `docker-compose.yml` and `nginx.conf` add an nginx reverse proxy
-with automatic Let's Encrypt certificates via certbot.
-
-1. Replace `darcs.example.com` with your domain in both `nginx.conf` and the
-   certbot command below.
+The included `docker-compose.yml` and `Caddyfile` add a Caddy reverse proxy
+that automatically provisions and renews Let's Encrypt certificates via ACME.
+
+1. Replace `darcs.example.com` with your domain in `Caddyfile`.
hunk ./README.md 234
-3. Start nginx and darcsweb (certbot needs nginx running for the ACME
-   challenge):
+3. Point DNS for your domain at the host and ensure ports 80 and 443 (TCP +
+   UDP) are reachable -- Caddy needs both for the ACME HTTP-01 challenge and
+   HTTP/3.
+4. Start the stack:
hunk ./README.md 240
-docker compose up -d nginx darcsweb
+docker compose up -d
hunk ./README.md 243
-4. Obtain the initial certificate:
-
-```
-docker compose run --rm certbot certonly --webroot \
-  -w /var/www/certbot -d darcs.example.com
-```
-
-5. Reload nginx to pick up the new certificate, then start the certbot
-   renewal sidecar:
-
-```
-docker compose exec nginx nginx -s reload
-docker compose up -d certbot
-```
-
-The certbot container renews certificates automatically every 12 hours (a
-no-op when they are not yet due). After renewal nginx must be reloaded; a
-cron entry on the host handles this:
-
-```
-0 */12 * * * docker compose exec nginx nginx -s reload
-```
-
-Certificate data is stored in the `certbot_conf` volume and persists across
+Caddy obtains the certificate on first launch and renews automatically.
+Certificate data is stored in the `caddy_data` volume and persists across
hunk ./docker-compose.yml 10
-  nginx:
-    image: nginx:stable-alpine
+  caddy:
+    image: caddy:2-alpine
hunk ./docker-compose.yml 15
+      - "443:443/udp"
hunk ./docker-compose.yml 17
-      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
-      - certbot_www:/var/www/certbot:ro
-      - certbot_conf:/etc/letsencrypt:ro
+      - ./Caddyfile:/etc/caddy/Caddyfile:ro
+      - caddy_data:/data
+      - caddy_config:/config
hunk ./docker-compose.yml 24
-  certbot:
-    image: certbot/certbot
-    volumes:
-      - certbot_www:/var/www/certbot
-      - certbot_conf:/etc/letsencrypt
-    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done'"
-
hunk ./docker-compose.yml 25
-  certbot_www:
-  certbot_conf:
+  caddy_data:
+  caddy_config:
hunk ./nginx.conf 1
-# Rate limiting zone: 10 requests/second per IP, 10MB zone
-limit_req_zone $binary_remote_addr zone=darcsweb:10m rate=10r/s;
-
-server {
-    listen 80;
-    server_name darcs.example.com;
-    server_tokens off;
-
-    location /.well-known/acme-challenge/ {
-        root /var/www/certbot;
-    }
-
-    location / {
-        return 301 https://$host$request_uri;
-    }
-}
-
-server {
-    listen 443 ssl;
-    http2 on;
-    server_name darcs.example.com;
-    server_tokens off;
-
-    ssl_certificate     /etc/letsencrypt/live/darcs.example.com/fullchain.pem;
-    ssl_certificate_key /etc/letsencrypt/live/darcs.example.com/privkey.pem;
-
-    ssl_protocols TLSv1.2 TLSv1.3;
-    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
-    ssl_prefer_server_ciphers off;
-
-    # OCSP stapling
-    ssl_stapling on;
-    ssl_stapling_verify on;
-
-    # Session resumption
-    ssl_session_timeout 1d;
-    ssl_session_cache shared:SSL:10m;
-    ssl_session_tickets off;
-
-    # HSTS: 2 years, include subdomains
-    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
-
-    # Limit request body and URI length
-    client_max_body_size 1k;
-    large_client_header_buffers 4 8k;
-
-    location / {
-        limit_req zone=darcsweb burst=20 nodelay;
-
-        proxy_pass http://darcsweb:3000;
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-        proxy_set_header X-Forwarded-Proto $scheme;
-
-        # Timeouts to prevent slow-loris attacks
-        proxy_connect_timeout 5s;
-        proxy_read_timeout 30s;
-        proxy_send_timeout 10s;
-    }
-}
rmfile ./nginx.conf
hunk ./src/DarcsWeb/Html.hs 40
-    , "<a href=\"/\"><img src=\"/static/darcs-logo.png\" alt=\"darcs\" class=\"header-logo\"></a>\n"
-    , breadcrumbs
+    , "<a href=\"/\" class=\"brand\" aria-label=\"darcs home\">"
+    , "<img src=\"/static/darcs-logo.png\" alt=\"darcs\" class=\"header-logo\">"
+    , "</a>\n"
+    , "<nav class=\"breadcrumbs\">", breadcrumbs, "</nav>\n"
hunk ./static/style.css 190
-  gap: var(--space-2);
+  flex-wrap: wrap;
+  gap: var(--space-4);
hunk ./static/style.css 204
-  opacity: 0.75;
hunk ./static/style.css 208
-  opacity: 1;
hunk ./static/style.css 211
+/* Brand chip: light background so the original dark-on-light logo
+   reads clearly against the dark header. */
+.page-header .brand {
+  background: #ffffff;
+  padding: 4px 12px;
+  border-radius: var(--radius-md);
+  box-shadow: var(--shadow-sm);
+  opacity: 1;
+  flex-shrink: 0;
+}
+
+.page-header .brand:hover {
+  background: #f8f6f2;
+}
+
hunk ./static/style.css 227
-  height: 24px;
+  height: 32px;
hunk ./static/style.css 229
-  vertical-align: middle;
-  filter: brightness(0) invert(1);
-  opacity: 0.9;
+  display: block;
hunk ./static/style.css 232
+.breadcrumbs {
+  color: var(--text-inverse);
+  opacity: 0.85;
+  display: inline-flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 0;
+  font-size: var(--size-sm);
+  min-width: 0;
+  overflow-wrap: anywhere;
+}
+
+.breadcrumbs a {
+  opacity: 0.85;
+}
+
+.breadcrumbs a:hover {
+  opacity: 1;
+  text-decoration: underline;
+  text-underline-offset: 2px;
+}
+
hunk ./static/style.css 805
+    gap: var(--space-2);
+  }
+
+  .page-header .brand {
+    padding: 3px 8px;
+  }
+
+  .header-logo {
+    height: 26px;