From 0fb66400be5a068b3996ec610b8bf59ad43bf752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Tue, 21 Jul 2026 16:41:08 +0100 Subject: [PATCH 01/10] feat: add Cap + valkey services and document Cap setup --- README.md | 41 ++++++++++++++++++++++++++++++++++++++++- docker-compose.yaml | 21 +++++++++++++++++++++ example.env | 11 +++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 524184c..b3fb0de 100644 --- a/README.md +++ b/README.md @@ -35,4 +35,43 @@ docker compose up -d This points the `web` service at `:edge` and adds [Watchtower](https://containrrr.dev/watchtower/), which polls every 5 minutes and auto-recreates `web` (and only `web`) when a new -image is published. \ No newline at end of file +image is published. + +## Cap CAPTCHA + +The compose file includes a [Cap](https://trycap.dev) self-hosted CAPTCHA +instance (the `cap` + `valkey` services). Cap protects the signup, login, +and password-reset forms. It is **opt-in at the application level**: the +services run by default, but the widget is not rendered and verification +is not performed until you set all three `CAP_*` variables on the `web` +service. + +### Setup + +1. Generate an admin key and set `CAP_ADMIN_KEY` in `.env`: + ```bash + openssl rand -hex 32 + ``` + +2. Start the services: + ```bash + docker compose up -d cap valkey + ``` + +3. Create a site key with the strongest challenge combination + (RSW time-lock + JS instrumentation): + ```bash + curl -X POST http://:3000/server/keys \ + -H "Authorization: Bot $CAP_ADMIN_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name":"shroud-email","instrumentation":true,"rsw":true}' + ``` + The response returns `siteKey` and `secretKey` (shown only once — save it). + +4. Set `CAP_SITE_KEY` and `CAP_SECRET_KEY` in `.env` and restart `web`: + ```bash + docker compose restart web + ``` + +Cap verifies tokens are single-use. The secret key never reaches the +browser; only the site key is public. \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 3810cb1..b577b6d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -43,6 +43,23 @@ services: - ./haraka/haraka_config:/app/haraka_config - pem_certs:/app/haraka_config/config/certs + cap: + image: tiago2/cap:latest + restart: unless-stopped + depends_on: + - valkey + environment: + - ADMIN_KEY=${CAP_ADMIN_KEY} + - REDIS_URL=redis://valkey:6379 + - CORS_ORIGIN=https://${APP_DOMAIN} + + valkey: + image: valkey/valkey:9-alpine + restart: unless-stopped + command: valkey-server --save 60 1 --loglevel warning --maxmemory-policy noeviction + volumes: + - valkey_data:/data + web: image: ghcr.io/shroud-email/shroud.email:1 restart: unless-stopped @@ -76,6 +93,9 @@ services: - LOOPS_ACTIVE_USERS_LIST_ID=${LOOPS_ACTIVE_USERS_LIST_ID} - CHATWOOT_MAC_TOKEN=${CHATWOOT_MAC_TOKEN} - CHATWOOT_BASE_URL=${CHATWOOT_BASE_URL} + - CAP_INSTANCE_URL=http://cap:3000 + - CAP_SITE_KEY=${CAP_SITE_KEY} + - CAP_SECRET_KEY=${CAP_SECRET_KEY} caddy: build: ./caddy @@ -105,3 +125,4 @@ volumes: db_data: caddy_data: pem_certs: + valkey_data: diff --git a/example.env b/example.env index 5ed78d9..6646ac9 100644 --- a/example.env +++ b/example.env @@ -50,3 +50,14 @@ CADDYFILE_PATH=./caddy/Caddyfile DB_USER=postgres DB_DATABASE=shroud + +## Cap CAPTCHA (optional but included in the default compose). +## Set all three to enable Cap on the signup/login/reset forms. +## CAP_ADMIN_KEY: dashboard password. Generate with: openssl rand -hex 32 +CAP_ADMIN_KEY= +## Create a site key (rsw + instrumentation) via the dashboard API: +## curl -X POST http://localhost:3000/server/keys \ +## -H "Authorization: Bot $CAP_ADMIN_KEY" \ +## -d '{"name":"shroud-email","instrumentation":true,"rsw":true}' +CAP_SITE_KEY= +CAP_SECRET_KEY= From 74e3d59949c0a0342a62abb5c7db9d37c6793e55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Tue, 21 Jul 2026 17:14:36 +0100 Subject: [PATCH 02/10] fix: correct Cap site-key auth flow in hosting docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README and example.env used 'Authorization: Bot $CAP_ADMIN_KEY' to create a site key, but Cap's Bot scheme authenticates against Valkey-stored API keys, not the ADMIN_KEY env var — so that curl always 401s. The correct flow: POST /auth/login with the admin_key to obtain a session token + hash, then POST /server/keys with a Bearer token (base64 of {token,hash}). Verified live against tiago2/cap:latest. Also notes that the production 'cap' service has no host port mapping, so the curl must run via a temporary ports override or 'docker compose exec' on the compose network (http://cap:3000). --- README.md | 19 +++++++++++++++++-- example.env | 11 +++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b3fb0de..9e09802 100644 --- a/README.md +++ b/README.md @@ -59,14 +59,29 @@ service. ``` 3. Create a site key with the strongest challenge combination - (RSW time-lock + JS instrumentation): + (RSW time-lock + JS instrumentation). Cap authenticates with a + session token issued by logging in with the `ADMIN_KEY` — the `Bot` + scheme is for Valkey-stored API keys, not the admin key: ```bash + # Log in with ADMIN_KEY → get a session token + hash + RESP=$(curl -s -X POST http://:3000/auth/login \ + -H "Content-Type: application/json" \ + -d "{\"admin_key\":\"$CAP_ADMIN_KEY\"}") + TOKEN=$(echo "$RESP" | jq -r .session_token) + HASH=$(echo "$RESP" | jq -r .hashed_token) + BEARER=$(printf '{"token":"%s","hash":"%s"}' "$TOKEN" "$HASH" | base64) + + # Create the site key with the Bearer session curl -X POST http://:3000/server/keys \ - -H "Authorization: Bot $CAP_ADMIN_KEY" \ + -H "Authorization: Bearer $BEARER" \ -H "Content-Type: application/json" \ -d '{"name":"shroud-email","instrumentation":true,"rsw":true}' ``` The response returns `siteKey` and `secretKey` (shown only once — save it). + (`:3000` must be reachable — the `cap` service has no host port + mapping in the production compose, so run this from the host with a + temporary `ports:` override, or `docker compose exec` into another service + on the compose network and use `http://cap:3000`.) 4. Set `CAP_SITE_KEY` and `CAP_SECRET_KEY` in `.env` and restart `web`: ```bash diff --git a/example.env b/example.env index 6646ac9..19b6dc7 100644 --- a/example.env +++ b/example.env @@ -55,9 +55,16 @@ DB_DATABASE=shroud ## Set all three to enable Cap on the signup/login/reset forms. ## CAP_ADMIN_KEY: dashboard password. Generate with: openssl rand -hex 32 CAP_ADMIN_KEY= -## Create a site key (rsw + instrumentation) via the dashboard API: +## Create a site key (rsw + instrumentation) by logging in with ADMIN_KEY +## first (Cap's Bot scheme is for API keys, not the admin key): +## RESP=$(curl -s -X POST http://localhost:3000/auth/login \ +## -H "Content-Type: application/json" \ +## -d "{\"admin_key\":\"$CAP_ADMIN_KEY\"}") +## BEARER=$(printf '{"token":"%s","hash":"%s"}' \ +## $(echo "$RESP" | jq -r .session_token) \ +## $(echo "$RESP" | jq -r .hashed_token) | base64) ## curl -X POST http://localhost:3000/server/keys \ -## -H "Authorization: Bot $CAP_ADMIN_KEY" \ +## -H "Authorization: Bearer $BEARER" \ ## -d '{"name":"shroud-email","instrumentation":true,"rsw":true}' CAP_SITE_KEY= CAP_SECRET_KEY= From 1b393c0cb1a952aa8d0d3959d000897950706302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Tue, 21 Jul 2026 17:34:00 +0100 Subject: [PATCH 03/10] fix: make CAP_INSTANCE_URL browser-reachable in hosting config The final whole-branch review found a production-breaking defect: the hosting compose hardcoded CAP_INSTANCE_URL=http://cap:3000, but that value is rendered into the browser's . http://cap:3000 is Docker-network-internal (unresolvable from a browser) and would be blocked as mixed content on an https://APP_DOMAIN page. With Cap 'enabled' the widget would never solve, fail-closing every register/login/reset POST for real users. Fix: make the compose use ${CAP_INSTANCE_URL} (not the hardcoded internal URL), document that it MUST be a browser-reachable HTTPS URL, and add it to example.env with guidance. The cap service stays internal-only; the self-hoster puts it behind their own ingress (e.g. a Caddy route) and points CAP_INSTANCE_URL at the public URL. --- README.md | 13 ++++++++++++- docker-compose.yaml | 2 +- example.env | 8 ++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9e09802..c91c484 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,15 @@ services run by default, but the widget is not rendered and verification is not performed until you set all three `CAP_*` variables on the `web` service. +> **Public ingress required.** `CAP_INSTANCE_URL` must be a URL a user's +> browser can reach over HTTPS — it is rendered into the widget's +> `data-cap-api-endpoint`, so an `http://` or container-internal URL will +> fail (mixed-content / unresolvable host) and the widget will never solve. +> The `cap` service is internal-only in this compose (no host port), so you +> must put it behind your own ingress (e.g. a Caddy route reverse-proxying +> `cap:3000` on a subdomain or path) and point `CAP_INSTANCE_URL` at that +> public HTTPS URL. Cap's `CORS_ORIGIN` is preset to `https://${APP_DOMAIN}`. + ### Setup 1. Generate an admin key and set `CAP_ADMIN_KEY` in `.env`: @@ -83,7 +92,9 @@ service. temporary `ports:` override, or `docker compose exec` into another service on the compose network and use `http://cap:3000`.) -4. Set `CAP_SITE_KEY` and `CAP_SECRET_KEY` in `.env` and restart `web`: +4. Set `CAP_INSTANCE_URL` (your public HTTPS Cap URL — see the ingress + note above), `CAP_SITE_KEY`, and `CAP_SECRET_KEY` in `.env`, then + restart `web`: ```bash docker compose restart web ``` diff --git a/docker-compose.yaml b/docker-compose.yaml index b577b6d..dbb82cb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -93,7 +93,7 @@ services: - LOOPS_ACTIVE_USERS_LIST_ID=${LOOPS_ACTIVE_USERS_LIST_ID} - CHATWOOT_MAC_TOKEN=${CHATWOOT_MAC_TOKEN} - CHATWOOT_BASE_URL=${CHATWOOT_BASE_URL} - - CAP_INSTANCE_URL=http://cap:3000 + - CAP_INSTANCE_URL=${CAP_INSTANCE_URL} - CAP_SITE_KEY=${CAP_SITE_KEY} - CAP_SECRET_KEY=${CAP_SECRET_KEY} diff --git a/example.env b/example.env index 19b6dc7..1b259db 100644 --- a/example.env +++ b/example.env @@ -55,6 +55,14 @@ DB_DATABASE=shroud ## Set all three to enable Cap on the signup/login/reset forms. ## CAP_ADMIN_KEY: dashboard password. Generate with: openssl rand -hex 32 CAP_ADMIN_KEY= +## CAP_INSTANCE_URL: the PUBLIC, browser-reachable HTTPS URL of your Cap +## instance. The widget renders this into data-cap-api-endpoint, so a user's +## browser must be able to reach it over HTTPS (http:// will be blocked as +## mixed content on your https://APP_DOMAIN pages). Cap is internal-only in +## this compose (no host port mapping), so point this at whatever public +## ingress fronts the `cap` service, e.g. https://cap.yourdomain.com or +## https://yourdomain.com/cap/. Leave unset (with the other two) to disable. +CAP_INSTANCE_URL= ## Create a site key (rsw + instrumentation) by logging in with ADMIN_KEY ## first (Cap's Bot scheme is for API keys, not the admin key): ## RESP=$(curl -s -X POST http://localhost:3000/auth/login \ From f66fc547c1fc69d833290f72e8e8fa1d824cf42b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Fri, 24 Jul 2026 14:46:59 +0100 Subject: [PATCH 04/10] fix: use base64 -w0 so Bearer token stays single-line on Linux --- README.md | 2 +- example.env | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c91c484..20ca943 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ service. -d "{\"admin_key\":\"$CAP_ADMIN_KEY\"}") TOKEN=$(echo "$RESP" | jq -r .session_token) HASH=$(echo "$RESP" | jq -r .hashed_token) - BEARER=$(printf '{"token":"%s","hash":"%s"}' "$TOKEN" "$HASH" | base64) + BEARER=$(printf '{"token":"%s","hash":"%s"}' "$TOKEN" "$HASH" | base64 -w0) # Create the site key with the Bearer session curl -X POST http://:3000/server/keys \ diff --git a/example.env b/example.env index 1b259db..c2a8899 100644 --- a/example.env +++ b/example.env @@ -70,7 +70,7 @@ CAP_INSTANCE_URL= ## -d "{\"admin_key\":\"$CAP_ADMIN_KEY\"}") ## BEARER=$(printf '{"token":"%s","hash":"%s"}' \ ## $(echo "$RESP" | jq -r .session_token) \ -## $(echo "$RESP" | jq -r .hashed_token) | base64) +## $(echo "$RESP" | jq -r .hashed_token) | base64 -w0) ## curl -X POST http://localhost:3000/server/keys \ ## -H "Authorization: Bearer $BEARER" \ ## -d '{"name":"shroud-email","instrumentation":true,"rsw":true}' From 5e8f3de8e691fa7dbdbae37b9b7bf2b57e176f95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Fri, 24 Jul 2026 14:47:01 +0100 Subject: [PATCH 05/10] fix: set valkey maxmemory so noeviction policy engages --- docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index dbb82cb..2a4bcdd 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -56,7 +56,7 @@ services: valkey: image: valkey/valkey:9-alpine restart: unless-stopped - command: valkey-server --save 60 1 --loglevel warning --maxmemory-policy noeviction + command: valkey-server --save 60 1 --loglevel warning --maxmemory 256mb --maxmemory-policy noeviction volumes: - valkey_data:/data From 02d6165a7f94e138a83bfda5bd58a2b8decfc7bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Fri, 24 Jul 2026 15:01:48 +0100 Subject: [PATCH 06/10] chore: pin cap image to major version 3 --- docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 2a4bcdd..264a79c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -44,7 +44,7 @@ services: - pem_certs:/app/haraka_config/config/certs cap: - image: tiago2/cap:latest + image: tiago2/cap:3 restart: unless-stopped depends_on: - valkey From 3a10a41f5acc195f0e605d77b301663080140706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Fri, 24 Jul 2026 15:08:55 +0100 Subject: [PATCH 07/10] update README --- README.md | 49 ++++++++----------------------------------------- 1 file changed, 8 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 20ca943..19f268f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Docker Compose configuration for self-hosting Shroud.email. Please read our [deployment documentation](https://shroud.email/docs/deployment/self-host) on our website. -If you just want to get up and running with Shroud.email quickly, you can sign up for our hosted version [here](https://app.shroud.email/users/register). +If you want to get up and running with Shroud.email quickly, and don't want to maintain your own mailserver, you can sign up for our hosted version [here](https://app.shroud.email/users/register). Copy `haraka/haraka_config/config/me.example` to `haraka/haraka_config/config/me` and set your mail hostname. @@ -24,7 +24,7 @@ leave the defaults get HTTP-01 and never need a Bunny key. ## Living on the edge -The committed `docker-compose.yaml` tracks the stable `:1` image. If you'd rather +The committed `docker-compose.yaml` tracks the stable image. If you'd rather run the latest `:edge` build (rebuilt on every push to `main`) and have it auto-update, copy the example override and bring the stack up: @@ -40,20 +40,13 @@ image is published. ## Cap CAPTCHA The compose file includes a [Cap](https://trycap.dev) self-hosted CAPTCHA -instance (the `cap` + `valkey` services). Cap protects the signup, login, -and password-reset forms. It is **opt-in at the application level**: the +instance. It is **opt-in at the application level**: the services run by default, but the widget is not rendered and verification is not performed until you set all three `CAP_*` variables on the `web` service. > **Public ingress required.** `CAP_INSTANCE_URL` must be a URL a user's -> browser can reach over HTTPS — it is rendered into the widget's -> `data-cap-api-endpoint`, so an `http://` or container-internal URL will -> fail (mixed-content / unresolvable host) and the widget will never solve. -> The `cap` service is internal-only in this compose (no host port), so you -> must put it behind your own ingress (e.g. a Caddy route reverse-proxying -> `cap:3000` on a subdomain or path) and point `CAP_INSTANCE_URL` at that -> public HTTPS URL. Cap's `CORS_ORIGIN` is preset to `https://${APP_DOMAIN}`. +> browser can reach over HTTPS. ### Setup @@ -67,37 +60,11 @@ service. docker compose up -d cap valkey ``` -3. Create a site key with the strongest challenge combination - (RSW time-lock + JS instrumentation). Cap authenticates with a - session token issued by logging in with the `ADMIN_KEY` — the `Bot` - scheme is for Valkey-stored API keys, not the admin key: - ```bash - # Log in with ADMIN_KEY → get a session token + hash - RESP=$(curl -s -X POST http://:3000/auth/login \ - -H "Content-Type: application/json" \ - -d "{\"admin_key\":\"$CAP_ADMIN_KEY\"}") - TOKEN=$(echo "$RESP" | jq -r .session_token) - HASH=$(echo "$RESP" | jq -r .hashed_token) - BEARER=$(printf '{"token":"%s","hash":"%s"}' "$TOKEN" "$HASH" | base64 -w0) - - # Create the site key with the Bearer session - curl -X POST http://:3000/server/keys \ - -H "Authorization: Bearer $BEARER" \ - -H "Content-Type: application/json" \ - -d '{"name":"shroud-email","instrumentation":true,"rsw":true}' - ``` - The response returns `siteKey` and `secretKey` (shown only once — save it). - (`:3000` must be reachable — the `cap` service has no host port - mapping in the production compose, so run this from the host with a - temporary `ports:` override, or `docker compose exec` into another service - on the compose network and use `http://cap:3000`.) - -4. Set `CAP_INSTANCE_URL` (your public HTTPS Cap URL — see the ingress - note above), `CAP_SITE_KEY`, and `CAP_SECRET_KEY` in `.env`, then +3. Create a site key. Cap authenticates with a + session token issued by logging in with the `ADMIN_KEY. Create a `siteKey` and `secretKey` in the Cap UI. + +4. Set `CAP_INSTANCE_URL`, `CAP_SITE_KEY`, and `CAP_SECRET_KEY` in `.env`, then restart `web`: ```bash docker compose restart web ``` - -Cap verifies tokens are single-use. The secret key never reaches the -browser; only the site key is public. \ No newline at end of file From 0293fc967d8ecbb3a40c998403060e97db660dab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Fri, 24 Jul 2026 17:41:06 +0100 Subject: [PATCH 08/10] feat: serve Cap via cap. subdomain through Caddy Add a cap.{APP_DOMAIN} site block reverse-proxying to the internal cap:3000 service, so the browser-rendered can reach Cap over HTTPS (the existing internal-only http://cap:3000 is unresolvable + mixed-content blocked on https pages). Default CAP_INSTANCE_URL to https://cap. so the stack works out-of-the-box once a site key is set, while remaining overridable in .env for self-hosters who want a path-based or different-domain setup. --- caddy/Caddyfile | 7 +++++++ docker-compose.yaml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/caddy/Caddyfile b/caddy/Caddyfile index 695a7bf..afb48fa 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -14,6 +14,13 @@ } } +cap.{$APP_DOMAIN} { + reverse_proxy cap:3000 + tls { + issuer acme + } +} + {$EMAIL_DOMAIN} { tls { issuer acme diff --git a/docker-compose.yaml b/docker-compose.yaml index 264a79c..32679b9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -93,7 +93,7 @@ services: - LOOPS_ACTIVE_USERS_LIST_ID=${LOOPS_ACTIVE_USERS_LIST_ID} - CHATWOOT_MAC_TOKEN=${CHATWOOT_MAC_TOKEN} - CHATWOOT_BASE_URL=${CHATWOOT_BASE_URL} - - CAP_INSTANCE_URL=${CAP_INSTANCE_URL} + - CAP_INSTANCE_URL=${CAP_INSTANCE_URL:-https://cap.${APP_DOMAIN}} - CAP_SITE_KEY=${CAP_SITE_KEY} - CAP_SECRET_KEY=${CAP_SECRET_KEY} From f7970a7a08e1404c36253647948083d1621f17de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Fri, 24 Jul 2026 17:52:18 +0100 Subject: [PATCH 09/10] feat: use CAP_DOMAIN for the Cap subdomain (overridable) cap.{APP_DOMAIN} baked the 'cap.' prefix in, so there was no way to serve Cap on a different subdomain (e.g. cap.shroud.email when APP_DOMAIN=app.shroud.email). Introduce CAP_DOMAIN as the single knob: - Caddyfile: cap.{} -> {$CAP_DOMAIN:disabled.localhost}. The :disabled.localhost default-token keeps the file valid when CAP_DOMAIN is unset (a bare {$CAP_DOMAIN} block is invalid Caddyfile and would break the whole stack for self-hosters who don't use Cap); Caddy issues no cert and routes no real traffic to the inert block. - compose: default CAP_DOMAIN to cap. on both web and caddy, and default CAP_INSTANCE_URL to https://. Nested-default resolution keeps web + caddy in sync; explicit overrides still win. - example.env: document CAP_DOMAIN and the now-derived CAP_INSTANCE_URL. --- caddy/Caddyfile | 2 +- docker-compose.yaml | 4 +++- example.env | 17 +++++++++++------ 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/caddy/Caddyfile b/caddy/Caddyfile index afb48fa..d19505f 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -14,7 +14,7 @@ } } -cap.{$APP_DOMAIN} { +{$CAP_DOMAIN:disabled.localhost} { reverse_proxy cap:3000 tls { issuer acme diff --git a/docker-compose.yaml b/docker-compose.yaml index 32679b9..b10aec1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -93,7 +93,8 @@ services: - LOOPS_ACTIVE_USERS_LIST_ID=${LOOPS_ACTIVE_USERS_LIST_ID} - CHATWOOT_MAC_TOKEN=${CHATWOOT_MAC_TOKEN} - CHATWOOT_BASE_URL=${CHATWOOT_BASE_URL} - - CAP_INSTANCE_URL=${CAP_INSTANCE_URL:-https://cap.${APP_DOMAIN}} + - CAP_DOMAIN=${CAP_DOMAIN:-cap.${APP_DOMAIN}} + - CAP_INSTANCE_URL=${CAP_INSTANCE_URL:-https://${CAP_DOMAIN:-cap.${APP_DOMAIN}}} - CAP_SITE_KEY=${CAP_SITE_KEY} - CAP_SECRET_KEY=${CAP_SECRET_KEY} @@ -105,6 +106,7 @@ services: - APP_DOMAIN=${APP_DOMAIN} - EMAIL_DOMAIN=${EMAIL_DOMAIN} - BUNNY_API_KEY=${BUNNY_API_KEY} + - CAP_DOMAIN=${CAP_DOMAIN:-cap.${APP_DOMAIN}} ports: - "80:80" - "443:443" diff --git a/example.env b/example.env index c2a8899..c3cbec5 100644 --- a/example.env +++ b/example.env @@ -55,13 +55,18 @@ DB_DATABASE=shroud ## Set all three to enable Cap on the signup/login/reset forms. ## CAP_ADMIN_KEY: dashboard password. Generate with: openssl rand -hex 32 CAP_ADMIN_KEY= +## CAP_DOMAIN: the public hostname Caddy will serve the Cap instance on +## (e.g. cap.shroud.email). Defaults to cap.${APP_DOMAIN}. Create a DNS +## A-record for this host pointing at the server, the same as APP_DOMAIN. +## Only set this if you want a different subdomain than the default. +## Caddy auto-provisions TLS for it via the same HTTP-01 ACME as APP_DOMAIN. +CAP_DOMAIN= ## CAP_INSTANCE_URL: the PUBLIC, browser-reachable HTTPS URL of your Cap -## instance. The widget renders this into data-cap-api-endpoint, so a user's -## browser must be able to reach it over HTTPS (http:// will be blocked as -## mixed content on your https://APP_DOMAIN pages). Cap is internal-only in -## this compose (no host port mapping), so point this at whatever public -## ingress fronts the `cap` service, e.g. https://cap.yourdomain.com or -## https://yourdomain.com/cap/. Leave unset (with the other two) to disable. +## instance. Defaults to https://${CAP_DOMAIN} (i.e. https://cap.${APP_DOMAIN} +## unless you set CAP_DOMAIN). The widget renders this into +## data-cap-api-endpoint, so a user's browser must be able to reach it over +## HTTPS (http:// will be blocked as mixed content on your https://APP_DOMAIN +## pages). Override only if you front Cap with a path-based or off-domain URL. CAP_INSTANCE_URL= ## Create a site key (rsw + instrumentation) by logging in with ADMIN_KEY ## first (Cap's Bot scheme is for API keys, not the admin key): From 00059d167d69d5d278cd67087fbbb04559504dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= Date: Fri, 24 Jul 2026 18:12:53 +0100 Subject: [PATCH 10/10] feat: add cap block to Caddyfile.bunny; require explicit CAP_DOMAIN - Caddyfile.bunny: mirror the cap block from Caddyfile so self-hosters using Bunny DNS-01 get the Cap route too (with dns bunny issuer, matching the APP_DOMAIN block). - docker-compose: stop defaulting CAP_DOMAIN to cap.${APP_DOMAIN}. It was the only thing making the CAP_INSTANCE_URL default complex (${CAP_INSTANCE_URL:-https://${CAP_DOMAIN:-cap.${APP_DOMAIN}}}). CAP_DOMAIN is now a required knob to enable Cap; when unset, Caddy leaves the route inert via the :disabled.localhost fallback and the app stays disabled. CAP_INSTANCE_URL simplifies to ${CAP_INSTANCE_URL:-https://${CAP_DOMAIN}}. - caddy service keeps CAP_DOMAIN=${CAP_DOMAIN} (no default) so Caddy sees the operator's value or falls back. - example.env: document CAP_DOMAIN as required, note both TLS paths. --- caddy/Caddyfile.bunny | 7 +++++++ docker-compose.yaml | 5 ++--- example.env | 12 ++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/caddy/Caddyfile.bunny b/caddy/Caddyfile.bunny index ab5b53d..c317987 100644 --- a/caddy/Caddyfile.bunny +++ b/caddy/Caddyfile.bunny @@ -14,6 +14,13 @@ } } +{$CAP_DOMAIN:disabled.localhost} { + reverse_proxy cap:3000 + tls { + dns bunny {env.BUNNY_API_KEY} + } +} + {$EMAIL_DOMAIN} { tls { issuer acme diff --git a/docker-compose.yaml b/docker-compose.yaml index b10aec1..e6966a5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -93,8 +93,7 @@ services: - LOOPS_ACTIVE_USERS_LIST_ID=${LOOPS_ACTIVE_USERS_LIST_ID} - CHATWOOT_MAC_TOKEN=${CHATWOOT_MAC_TOKEN} - CHATWOOT_BASE_URL=${CHATWOOT_BASE_URL} - - CAP_DOMAIN=${CAP_DOMAIN:-cap.${APP_DOMAIN}} - - CAP_INSTANCE_URL=${CAP_INSTANCE_URL:-https://${CAP_DOMAIN:-cap.${APP_DOMAIN}}} + - CAP_INSTANCE_URL=${CAP_INSTANCE_URL:-https://${CAP_DOMAIN}} - CAP_SITE_KEY=${CAP_SITE_KEY} - CAP_SECRET_KEY=${CAP_SECRET_KEY} @@ -106,7 +105,7 @@ services: - APP_DOMAIN=${APP_DOMAIN} - EMAIL_DOMAIN=${EMAIL_DOMAIN} - BUNNY_API_KEY=${BUNNY_API_KEY} - - CAP_DOMAIN=${CAP_DOMAIN:-cap.${APP_DOMAIN}} + - CAP_DOMAIN=${CAP_DOMAIN} ports: - "80:80" - "443:443" diff --git a/example.env b/example.env index c3cbec5..cb15137 100644 --- a/example.env +++ b/example.env @@ -56,14 +56,14 @@ DB_DATABASE=shroud ## CAP_ADMIN_KEY: dashboard password. Generate with: openssl rand -hex 32 CAP_ADMIN_KEY= ## CAP_DOMAIN: the public hostname Caddy will serve the Cap instance on -## (e.g. cap.shroud.email). Defaults to cap.${APP_DOMAIN}. Create a DNS -## A-record for this host pointing at the server, the same as APP_DOMAIN. -## Only set this if you want a different subdomain than the default. -## Caddy auto-provisions TLS for it via the same HTTP-01 ACME as APP_DOMAIN. +## (e.g. cap.shroud.email). Required to enable Cap — create a DNS A-record +## for this host pointing at the server, the same as APP_DOMAIN. +## Caddy auto-provisions TLS for it (HTTP-01 by default, or Bunny DNS-01 if +## you set CADDYFILE_PATH=./caddy/Caddyfile.bunny). When unset, Caddy +## leaves the Cap route inert (no cert, no traffic) and Cap stays disabled. CAP_DOMAIN= ## CAP_INSTANCE_URL: the PUBLIC, browser-reachable HTTPS URL of your Cap -## instance. Defaults to https://${CAP_DOMAIN} (i.e. https://cap.${APP_DOMAIN} -## unless you set CAP_DOMAIN). The widget renders this into +## instance. Defaults to https://${CAP_DOMAIN}. The widget renders this into ## data-cap-api-endpoint, so a user's browser must be able to reach it over ## HTTPS (http:// will be blocked as mixed content on your https://APP_DOMAIN ## pages). Override only if you front Cap with a path-based or off-domain URL.