LibrePortal/containers/stalwart/docker-compose.yml
librelad 63f276523b stalwart: run as container-root so it can write its own data directory
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>
2026-08-18 05:14:19 +01:00

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