Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ 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).

## 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:

Expand All @@ -19,4 +19,36 @@ 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.
image is published.

## Cap CAPTCHA

The compose file includes a [Cap](https://trycap.dev) self-hosted CAPTCHA
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.

### 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. 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
```
10 changes: 5 additions & 5 deletions cron/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ RUN apk update && \

WORKDIR /workdir

COPY lets-encrypt-r4.pem /workdir/lets-encrypt-r4.pem
COPY bundle_certs.sh /etc/periodic/daily/bundle_certs

RUN chmod +x /etc/periodic/daily/bundle_certs
COPY bundle_certs.sh /usr/local/bin/bundle_certs
RUN chmod +x /usr/local/bin/bundle_certs

VOLUME /pem

CMD ["crond", "-f", "-l", "0"]
# Run bundle once at boot (populate pem_certs before Haraka's first STARTTLS),
# then keep the daily crond for renewals. Caddy's fullchain is copied verbatim.
CMD ["sh", "-c", "bundle_certs; exec crond -f -l 0"]
19 changes: 14 additions & 5 deletions cron/bundle_certs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@ set -e

if [ -z "$EMAIL_DOMAIN" ]; then echo "EMAIL_DOMAIN is not set"; exit 1; fi

cd /workdir
CERT_DIR="/caddy/certificates/acme-v02.api.letsencrypt.org-directory/$EMAIL_DOMAIN"
LEAF="$CERT_DIR/${EMAIL_DOMAIN}.crt"
KEY="$CERT_DIR/${EMAIL_DOMAIN}.key"

if [ ! -s "$LEAF" ] || [ ! -s "$KEY" ]; then
echo "Caddy cert not ready yet ($LEAF); skipping"
exit 0
fi

echo "Copying Caddy certs to Haraka..."
cd "/caddy/certificates/acme-v02.api.letsencrypt.org-directory/$EMAIL_DOMAIN"
cp "${EMAIL_DOMAIN}.key" /pem/tls_key.pem
cat "${EMAIL_DOMAIN}.crt" /workdir/lets-encrypt-r4.pem > /pem/tls_cert.pem
echo "Copied Caddy certs to Haraka."
# Caddy's {domain}.crt is already the full chain (leaf + intermediates).
# Copy it verbatim — appending a separate intermediate would duplicate/break the chain.
cp "$KEY" /pem/tls_key.pem
cp "$LEAF" /pem/tls_cert.pem
echo "Copied Caddy certs to Haraka."
30 changes: 0 additions & 30 deletions cron/lets-encrypt-r4.pem

This file was deleted.

21 changes: 21 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,23 @@ services:
- ./haraka/haraka_config:/app/haraka_config
- pem_certs:/app/haraka_config/config/certs

cap:
image: tiago2/cap:3
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 256mb --maxmemory-policy noeviction
volumes:
- valkey_data:/data

web:
image: ghcr.io/shroud-email/shroud.email:1
restart: unless-stopped
Expand Down Expand Up @@ -74,6 +91,9 @@ services:
- S3_HOST=${S3_HOST}
- LOOPS_API_KEY=${LOOPS_API_KEY}
- LOOPS_ACTIVE_USERS_LIST_ID=${LOOPS_ACTIVE_USERS_LIST_ID}
- CAP_INSTANCE_URL=${CAP_INSTANCE_URL}
- CAP_SITE_KEY=${CAP_SITE_KEY}
- CAP_SECRET_KEY=${CAP_SECRET_KEY}

caddy:
image: ghcr.io/shroud-email/caddy-permissive-file-storage:main
Expand Down Expand Up @@ -102,3 +122,4 @@ volumes:
db_data:
caddy_data:
pem_certs:
valkey_data:
26 changes: 26 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,29 @@ S3_HOST=replace

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=
## 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 \
## -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 -w0)
## curl -X POST http://localhost:3000/server/keys \
## -H "Authorization: Bearer $BEARER" \
## -d '{"name":"shroud-email","instrumentation":true,"rsw":true}'
CAP_SITE_KEY=
CAP_SECRET_KEY=
5 changes: 5 additions & 0 deletions haraka/haraka_config/config/tls.ini
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
key=certs/tls_key.pem
cert=certs/tls_cert.pem

; Disable client-initiated TLS renegotiation (DoS via repeated handshakes).
; Value is crypto.constants.SSL_OP_NO_RENEGOTIATION (0x40000000), passed
; through to tls.createSecureContext as secureOptions by Haraka's tls_socket.
secureOptions=1073741824
Loading