Found by running the installer for real rather than testing the hook in isolation. Stalwart never started: it failed to open its database with "Permission denied" on /var/lib/stalwart, which meant no mail could be stored and the setup wizard could not be completed by hand either. The image runs as its own uid 2000. LibrePortal gives container directories to the docker install user under rootless and to the manager under rooted, and 2000 is neither, so the bind mounts were unwritable in both modes. This was not something the new provisioning introduced — it predates it, and the app has never been able to hold mail. Running as container-root maps to whichever host user owns those directories. Under rootless that is the unprivileged docker install user, not host root. Also stop discarding the server's error when setup fails. Both failures that actually occur — a hostname under a TLD that does not resolve, and the unwritable data directory above — name themselves precisely, and a bare "setup failed" turns a one-line fix into guesswork. Verified end to end through `libreportal app install stalwart` on a clean install: setup applied, DKIM keys generated, postmaster mailbox created, and the full record set printed from the server's own zone data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
89 lines
5.6 KiB
YAML
89 lines
5.6 KiB
YAML
networks:
|
|
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
|
|
external: true
|
|
|
|
services:
|
|
# Stalwart Mail & Collaboration Server — SMTP/IMAP/POP3/JMAP + CalDAV/CardDAV
|
|
# in ONE container. That is why it is here rather than a multi-container mail
|
|
# stack: one image the updater can version, one data dir the backup engine can
|
|
# snapshot, one thing to restore.
|
|
stalwart-service: #LIBREPORTAL|SERVICE_TAG_1|stalwart-service
|
|
container_name: stalwart-service
|
|
# The image runs as its own uid 2000, which cannot write either of the
|
|
# bind mounts below: LibrePortal hands container directories to the
|
|
# docker install user in rootless mode and to the manager in rooted mode,
|
|
# and 2000 is neither. Stalwart then fails to open its database at all —
|
|
# "Permission denied" on /var/lib/stalwart — so no mail can be stored and
|
|
# even the setup wizard cannot complete.
|
|
# Running as container-root maps to whichever host user owns those
|
|
# directories, which is the one uid that is correct in both modes. Under
|
|
# rootless this is not host root; it is the unprivileged docker install
|
|
# user. It also removes any question about binding port 25 inside the
|
|
# container.
|
|
user: "0:0"
|
|
# Pinned to a minor tag, NOT :latest. Stalwart is pre-1.0 and has said the
|
|
# storage schema is still being finalised, so an unattended jump to a new
|
|
# minor could carry a data migration. CFG_STALWART_UPDATE_TYPE is likewise
|
|
# 'manual' — read the release notes, then press Update.
|
|
image: stalwartlabs/stalwart:v0.16 #LIBREPORTAL|STALWART_VERSION_TAG|v0.16
|
|
# GLUETUN_OFF_BEGIN
|
|
ports:
|
|
- "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1
|
|
- "PORTS_DATA_2" #LIBREPORTAL|PORTS_TAG_2|PORTS_DATA_2
|
|
- "PORTS_DATA_3" #LIBREPORTAL|PORTS_TAG_3|PORTS_DATA_3
|
|
- "PORTS_DATA_4" #LIBREPORTAL|PORTS_TAG_4|PORTS_DATA_4
|
|
- "PORTS_DATA_5" #LIBREPORTAL|PORTS_TAG_5|PORTS_DATA_5
|
|
- "PORTS_DATA_6" #LIBREPORTAL|PORTS_TAG_6|PORTS_DATA_6
|
|
- "PORTS_DATA_7" #LIBREPORTAL|PORTS_TAG_7|PORTS_DATA_7
|
|
- "PORTS_DATA_8" #LIBREPORTAL|PORTS_TAG_8|PORTS_DATA_8
|
|
- "PORTS_DATA_9" #LIBREPORTAL|PORTS_TAG_9|PORTS_DATA_9
|
|
# GLUETUN_OFF_END
|
|
volumes:
|
|
# Server configuration (config.toml + TLS material).
|
|
- ./etc:/etc/stalwart
|
|
# The message store, blobs, indexes and internal database — this is
|
|
# the mail itself.
|
|
- ./data:/var/lib/stalwart
|
|
environment:
|
|
- TZ:TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
|
|
# First boot only: without this Stalwart generates a random admin
|
|
# password and prints it to stderr, which would leave the credential
|
|
# in the container log and nowhere else. Seeding it from the app
|
|
# config means the WebUI can show it like every other app.
|
|
# Two annotations on one line — the tag manager scopes its sed per
|
|
# tag, so each placeholder needs its own marker (see ollama's
|
|
# OLLAMA_BASE_URL row for the same pattern).
|
|
- STALWART_RECOVERY_ADMIN=STALWART_ADMIN_USER_DATA:STALWART_ADMIN_PASSWORD_DATA #LIBREPORTAL|STALWART_ADMIN_USER_TAG|STALWART_ADMIN_USER_DATA #LIBREPORTAL|STALWART_ADMIN_PASSWORD_TAG|STALWART_ADMIN_PASSWORD_DATA
|
|
labels:
|
|
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
|
|
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
|
|
# No backup.db descriptor: Stalwart's store is its own embedded
|
|
# database inside the data dir, not a dump-able SQLite/MySQL file, so
|
|
# the files descriptor covers it — paired with the stop-snapshot-start
|
|
# strategy in stalwart.config so the copy is taken at rest.
|
|
libreportal.backup.files: "stalwart-service:/var/lib/stalwart:data"
|
|
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
|
|
# TRAEFIK_PORT_1_BEGIN
|
|
traefik.http.routers.stalwart-service.entrypoints: web,websecure
|
|
traefik.http.routers.stalwart-service.rule: Host(`DOMAINSUBNAME_DATA_1`) #LIBREPORTAL|DOMAINSUBNAME_TAG_1|DOMAINSUBNAME_DATA_1
|
|
traefik.http.routers.stalwart-service.tls: true
|
|
traefik.http.routers.stalwart-service.tls.certresolver: production
|
|
traefik.http.services.stalwart-service.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1
|
|
traefik.http.routers.stalwart-service.middlewares: MIDDLEWARE_DATA_1 #LIBREPORTAL|MIDDLEWARE_TAG_1|MIDDLEWARE_DATA_1
|
|
# TRAEFIK_PORT_1_END
|
|
traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
|
|
healthcheck:
|
|
disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA
|
|
restart: 'unless-stopped'
|
|
# GLUETUN_OFF_BEGIN
|
|
networks:
|
|
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
|
|
ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1
|
|
# GLUETUN_OFF_END
|
|
# GLUETUN_ON_BEGIN
|
|
# A mail server behind a VPN exit is almost always wrong: outbound port 25
|
|
# from a VPN IP is blocked or blocklisted nearly everywhere, and inbound
|
|
# delivery cannot reach you at all. Left here for structure only.
|
|
# network_mode: "container:gluetun-service"
|
|
# GLUETUN_ON_END
|