feat: initial Stalwart mail server recipe with optional Bulwark webmail

This commit is contained in:
2026-08-04 13:56:51 +02:00
parent 026e06cb04
commit 7b54628938
5 changed files with 166 additions and 66 deletions
-23
View File
@@ -1,28 +1,5 @@
---
kind: pipeline
name: deploy to swarm-test.autonomic.zone
steps:
- name: deployment
image: git.coopcloud.tech/coop-cloud/stack-ssh-deploy:latest
settings:
host: swarm-test.autonomic.zone
stack: example_com # UPDATE ME
generate_secrets: true
purge: true
deploy_key:
from_secret: drone_ssh_swarm_test
networks:
- proxy
environment:
DOMAIN: example.swarm-test.autonomic.zone # UPDATE ME
STACK_NAME: example_com # UPDATE ME
LETS_ENCRYPT_ENV: staging
# Also set any config versions from abra.sh
trigger:
branch:
- main
---
kind: pipeline
name: generate recipe catalogue
steps:
- name: release a new version
+13 -6
View File
@@ -1,8 +1,15 @@
TYPE={{ .Name }}
DOMAIN={{ .Name }}.example.com
## Domain aliases
#EXTRA_DOMAINS=', `www.{{ .Name }}.example.com`'
TYPE=git.coopcloud.tech/eCommons/stalwart
# Primary hostname for the mail server — used for admin UI, JMAP, and autodiscover
DOMAIN=git.coopcloud.tech/eCommons/stalwart.example.com
# The domain part only (without "mail.") — used for autodiscover/autoconfig/mta-sts subdomains
BASE_DOMAIN=example.com
LETS_ENCRYPT_ENV=production
ENABLE_BACKUPS=true
COMPOSE_FILE="compose.yml"
# Bulwark webmail #
#COMPOSE_FILE="compose.yml:compose.bulwark.yml"
#WEBMAIL_DOMAIN=webmail.example.com
#SECRET_SESSION_SEC_VERSION=v1 #length=64
+61 -14
View File
@@ -1,24 +1,71 @@
# {{ .Name }}
# stalwart
{{ .Description }}
All-in-one Mail & Collaboration server. Secure, scalable and fluent in every protocol (IMAP, JMAP, SMTP, CalDAV, CardDAV, WebDAV).
<!-- metadata -->
* **Category**: {{ .Category }}
* **Status**: {{ .Status }}
* **Image**: {{ .Image }}
* **Healthcheck**: {{ .Healthcheck }}
* **Backups**: {{ .Backups }}
* **Email**: {{ .Email }}
* **Tests**: {{ .Tests }}
* **SSO**: {{ .SSO }}
* **Category**: Apps
* **Status**: 0
* **Image**: [`stalwartlabs/stalwart`](https://hub.docker.com/r/stalwartlabs/stalwart), 4
* **Healthcheck**: Yes
* **Backups**: Yes
* **Email**: Yes
* **Tests**: No
* **SSO**: Yes
<!-- endmetadata -->
## Quick start
* `abra app new {{ .Name }} --secrets`
* `abra app config <app-name>`
* `abra app deploy <app-name>`
```bash
abra app new stalwart --secrets
abra app config <app-name> # set DOMAIN, BASE_DOMAIN
abra app deploy <app-name>
```
For more, see [`docs.coopcloud.tech`](https://docs.coopcloud.tech).
On first deploy, Stalwart generates a random admin password and prints it to logs:
```bash
abra app logs <app-name> app
```
Then open `https://mail.example.com/admin` to complete the setup wizard.
## Post-deploy configuration (required)
After the setup wizard, apply these settings in the Stalwart admin UI under **Network → Servuces → HTTP → General**:
- Enable **Obtain remote IP from Forwarded header** — required so Stalwart sees real client IPs from Traefik
## Ports
| Port | Mode | Protocol | Purpose |
|------|------|----------|---------|
| 25 | Host | SMTP | Inbound mail from other servers |
| 465 | Host | SMTPS | Client mail submission (implicit TLS) |
| 587 | Host | Submission | Client mail submission (STARTTLS) |
| 993 | Host | IMAPS | Client mail retrieval (implicit TLS) |
| 443 | Traefik | HTTPS | Admin UI, JMAP, autodiscover, webmail |
## Bulwark webmail (optional)
To enable the Bulwark webmail client at `webmail.example.com`:
1. Add a DNS record: `webmail.example.com` CNAME → `mail.example.com`
2. Uncomment the three Bulwark lines in `.env`:
```
COMPOSE_FILE="compose.yml:compose.bulwark.yml"
WEBMAIL_DOMAIN=webmail.example.com
SECRET_BULWARK_SESSION_SECRET_VERSION=v1
```
3. Generate the session secret: `abra app secret generate <app-name>`
4. Redeploy: `abra app deploy <app-name>`
When Bulwark is active, `https://mail.example.com/` redirects to `https://webmail.example.com/`. The admin interface remains at `https://mail.example.com/admin`.
## Backup
Backupbot backs up both volumes:
- `/etc/stalwart` - server configuration
- `/var/lib/stalwart` - RocksDB mail data (hot copy; RocksDB's WAL ensures the backup is recoverable on restore)
+61
View File
@@ -0,0 +1,61 @@
---
services:
app:
deploy:
labels:
- "traefik.http.routers.${STACK_NAME}-mail-root.rule=Host(`${DOMAIN}`) && Path(`/`)"
- "traefik.http.routers.${STACK_NAME}-mail-root.entrypoints=web-secure"
- "traefik.http.routers.${STACK_NAME}-mail-root.tls.certresolver=${LETS_ENCRYPT_ENV}"
- "traefik.http.routers.${STACK_NAME}-mail-root.middlewares=${STACK_NAME}-to-webmail"
- "traefik.http.routers.${STACK_NAME}-mail-root.priority=1000"
- "traefik.http.middlewares.${STACK_NAME}-to-webmail.redirectregex.regex=.*"
- "traefik.http.middlewares.${STACK_NAME}-to-webmail.redirectregex.replacement=https://${WEBMAIL_DOMAIN}/"
- "traefik.http.middlewares.${STACK_NAME}-to-webmail.redirectregex.permanent=true"
bulwark:
image: ghcr.io/bulwarkmail/webmail:1.7.8
networks:
- proxy
volumes:
- bulwark-admin:/app/data/admin
- bulwark-admin-state:/app/data/admin-state
- bulwark-settings:/app/data/settings
environment:
- SESSION_SECRET_FILE=/run/secrets/session_sec
- JMAP_SERVER_URL=https://${DOMAIN}
- SETTINGS_SYNC_ENABLED=true
secrets:
- session_sec
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://127.0.0.1:3000/api/health",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
restart_policy:
condition: on-failure
labels:
- "traefik.enable=true"
- "traefik.http.routers.${STACK_NAME}-webmail.rule=Host(`${WEBMAIL_DOMAIN}`)"
- "traefik.http.routers.${STACK_NAME}-webmail.entrypoints=web-secure"
- "traefik.http.routers.${STACK_NAME}-webmail.tls.certresolver=${LETS_ENCRYPT_ENV}"
- "traefik.http.services.${STACK_NAME}-webmail.loadbalancer.server.port=3000"
secrets:
session_sec:
external: true
name: ${STACK_NAME}_session_sec_${SECRET_SESSION_SEC_VERSION}
volumes:
bulwark-admin:
bulwark-admin-state:
bulwark-settings:
+31 -23
View File
@@ -1,39 +1,47 @@
---
services:
app:
image: nginx:1.27.5
image: stalwartlabs/stalwart:v0.16.15
networks:
- proxy
ports:
- target: 25
published: 25
protocol: tcp
mode: host
- target: 465
published: 465
protocol: tcp
mode: host
- target: 587
published: 587
protocol: tcp
mode: host
- target: 993
published: 993
protocol: tcp
mode: host
volumes:
- config:/etc/stalwart
- data:/var/lib/stalwart
deploy:
restart_policy:
condition: on-failure
update_config:
failure_action: rollback
order: stop-first
labels:
- "traefik.enable=true"
- "traefik.http.services.${STACK_NAME}.loadbalancer.server.port=80"
- "traefik.http.routers.${STACK_NAME}.rule=Host(`${DOMAIN}`${EXTRA_DOMAINS})"
- "traefik.http.routers.${STACK_NAME}.rule=Host(`${DOMAIN}`) || Host(`autodiscover.${BASE_DOMAIN}`) || Host(`autoconfig.${BASE_DOMAIN}`) || Host(`mta-sts.${BASE_DOMAIN}`)"
- "traefik.http.routers.${STACK_NAME}.entrypoints=web-secure"
- "traefik.http.routers.${STACK_NAME}.tls.certresolver=${LETS_ENCRYPT_ENV}"
## Edit the following line if you are using one, but not both, "Redirect" sections below
#- "traefik.http.routers.${STACK_NAME}.middlewares=${STACK_NAME}-redirectscheme,${STACK_NAME}-redirecthostname"
## Redirect from EXTRA_DOMAINS to DOMAIN
# - "traefik.http.middlewares.${STACK_NAME}-redirecthostname.redirectregex.regex=^http[s]?://([^/]*)/(.*)"
# - "traefik.http.middlewares.${STACK_NAME}-redirecthostname.redirectregex.replacement=https://${DOMAIN}/$${2}"
# - "traefik.http.middlewares.${STACK_NAME}-redirecthostname.redirectregex.permanent=true"
## Redirect HTTP to HTTPS
# - "traefik.http.middlewares.${STACK_NAME}-redirectscheme.redirectscheme.scheme=https"
# - "traefik.http.middlewares.${STACK_NAME}-redirectscheme.redirectscheme.permanent=true"
## When you're ready for release, run "abra recipe sync <name>" to set this
- "coop-cloud.${STACK_NAME}.version="
## Enable backups: https://docs.coopcloud.tech/maintainers/handbook/#how-do-i-configure-backuprestore
# - "backupbot.backup=true"
# - "backupbot.backup.path=/some/path"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost"]
interval: 30s
timeout: 10s
retries: 10
start_period: 1m
- "traefik.http.services.${STACK_NAME}.loadbalancer.server.port=8080"
- "backupbot.backup=${ENABLE_BACKUPS:-true}"
networks:
proxy:
external: true
volumes:
config:
data: