Files
webmail-client/docker-compose.yml
T

421 lines
16 KiB
YAML
Raw Normal View History

# Production stack for a single box (Synology / QNAP / any Docker host).
#
# Data lives in bind mounts under ${DATA_ROOT} — NOT in named volumes — so it
# survives `docker compose down`, image updates, project deletion in the NAS
# UI, and a full reinstall, and so the NAS backup tools (Hyper Backup,
# Snapshot Replication, HBS3) can actually see it.
#
# Nothing is published except ${WEB_PORT}. Postgres, Redis, Meilisearch and the
# API are reachable only on the internal `webmail-prod` network.
#
# This file lives at the repo ROOT on purpose. Synology Container Manager and
# QNAP Container Station build a project from a folder and read
# `docker-compose.yml` from it, and DSM's validator rejects newer compose
# features (`include:` among them) — so this is one plain, flat file with no
# indirection. Keep it that way.
#
# Note `docker/docker-compose.yml` is something else entirely: the throwaway
# DEV service stack (Postgres/Redis/Meilisearch/GreenMail on dev ports). Do
# not point a NAS project at the docker/ folder.
#
# Start: ./deploy/webmail.sh up
# Manual: docker compose up -d (reads .env from this directory)
#
# The odd-looking DATA_ROOT default is deliberate: if no .env is found, the
# daemon's "Bind mount failed: '<path>' does not exist" error then names the
# fix instead of pointing at a directory nobody meant to create.
#
# Likewise, no `cpus:` limits: that maps to NanoCPUs, which DSM's Docker
# daemon rejects ("NanoCPUs can not be set"). mem_limit uses the much older
# Memory field and works fine, so memory caps are the throttle here.
#
# Interpolation here must stay on the oldest, dumbest forms: ${VAR} and
# ${VAR:-default}. DSM's bundled Compose rejects `${VAR:?message}` with
# "invalid interpolation format", and rejects `include:` outright. Required
# values are enforced by deploy/webmail.sh and `webmail.sh doctor` instead.
#
# Project name is deliberately NOT "webmail": docker/docker-compose.yml uses
# that, and two compose files sharing one project name fight over each other's
# containers — running `make up` would stop production.
name: webmail-prod
x-logging: &logging
# A shared NAS must never have its volume filled by container logs.
driver: json-file
options: { max-size: "10m", max-file: "3" }
x-backend-image: &backend-image
image: ${REGISTRY_PREFIX:-webmail}/backend:${IMAGE_TAG:-local}
build:
context: .
dockerfile: docker/Dockerfile.backend
args:
APP_VERSION: ${IMAGE_TAG:-local}
GIT_SHA: ${GIT_SHA:-unknown}
x-backend-env: &backend-env
APP_DOMAIN: "${APP_DOMAIN:-APP_DOMAIN-unset}"
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-webmail}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-webmail}
REDIS_URL: redis://redis:6379/0
MASTER_KEY: "${MASTER_KEY}"
BLOB_ROOT: /data/blobs
SEARCH_BACKEND: ${SEARCH_BACKEND:-pg}
MEILI_URL: ${MEILI_URL:-http://meilisearch:7700}
MEILI_KEY: ${MEILI_MASTER_KEY:-}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
UNDO_SECONDS: ${UNDO_SECONDS:-10}
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
MAX_ATTACHMENT_MB: ${MAX_ATTACHMENT_MB:-25}
TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30}
SENDER_AVATARS_ENABLED: ${SENDER_AVATARS_ENABLED:-true}
GOOGLE_OAUTH_ENABLED: ${GOOGLE_OAUTH_ENABLED:-false}
OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID:-}
OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET:-}
OAUTH_MS_CLIENT_ID: ${OAUTH_MS_CLIENT_ID:-}
OAUTH_MS_CLIENT_SECRET: ${OAUTH_MS_CLIENT_SECRET:-}
# Worker sizing — see docker/.env.prod.example for what these cost in RAM.
INTERACTIVE_CONCURRENCY: ${INTERACTIVE_CONCURRENCY:-8}
BULK_CONCURRENCY: ${BULK_CONCURRENCY:-1}
PARSE_CONCURRENCY: ${PARSE_CONCURRENCY:-2}
STREAM_PARSE_MB: ${STREAM_PARSE_MB:-8}
PER_HOST_CONN_CAP: ${PER_HOST_CONN_CAP:-10}
MAX_ACCOUNTS_PER_WORKER: ${MAX_ACCOUNTS_PER_WORKER:-400}
HOT_BUDGET_PER_WORKER: ${HOT_BUDGET_PER_WORKER:-50}
APP_VERSION: ${IMAGE_TAG:-local}
TZ: ${TZ:-UTC}
services:
# One-shot: make the bind-mounted host folders writable by the unprivileged
# container users before anything tries to use them. Runs as root by design.
init:
image: alpine:3.20
user: root
command:
- sh
- -c
- |
set -e
mkdir -p /mnt/postgres /mnt/blobs /mnt/backups /mnt/redis /mnt/meili /mnt/state
# Only recurse when ownership is actually wrong: `chown -R` over a large
# blob store on every start would be minutes of pointless disk churn.
[ "$$(stat -c %u /mnt/blobs)" = "10001" ] || chown -R 10001:10001 /mnt/blobs
[ "$$(stat -c %u /mnt/backups)" = "999" ] || chown -R 999:999 /mnt/backups
[ "$$(stat -c %u /mnt/redis)" = "999" ] || chown -R 999:999 /mnt/redis
chmod 700 /mnt/backups
echo "[init] data directories ready"
volumes:
# ONE mount, of DATA_ROOT itself. Synology's Docker daemon refuses to
# create a missing bind-mount source ("Bind mount failed: ... doesn't
# exists") instead of creating it the way Docker does elsewhere, so every
# subdirectory mounted below must exist before its container is created.
# Mounting the parent here means only DATA_ROOT has to pre-exist, and this
# service creates the rest — which is why everything now depends on it.
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}:/mnt
restart: "no"
logging: *logging
postgres:
image: postgres:16
environment:
POSTGRES_USER: ${POSTGRES_USER:-webmail}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-webmail}
# Keeps the data directory one level down, so the bind mount can hold lost+found
# and NAS metadata files without initdb refusing to start.
PGDATA: /var/lib/postgresql/data/pgdata
TZ: ${TZ:-UTC}
command:
- postgres
- -c
- shared_buffers=${PG_SHARED_BUFFERS:-256MB}
- -c
- max_connections=${PG_MAX_CONNECTIONS:-100}
- -c
- work_mem=${PG_WORK_MEM:-8MB}
- -c
- maintenance_work_mem=${PG_MAINTENANCE_WORK_MEM:-128MB}
- -c
- effective_cache_size=${PG_EFFECTIVE_CACHE_SIZE:-768MB}
# Full page writes stay ON: NAS filesystems (btrfs/ext4) are not 8k-atomic.
volumes:
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/postgres:/var/lib/postgresql/data
depends_on:
init: { condition: service_completed_successfully }
healthcheck:
# -h 127.0.0.1 forces a TCP check. Without it pg_isready uses the Unix
# socket, which the temporary server that the postgres image runs during
# first-time initdb DOES answer (it listens on the socket only) — so the
# container went healthy before TCP was up, and dependents connecting to
# postgres:5432 hung until they timed out.
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U ${POSTGRES_USER:-webmail} -d ${POSTGRES_DB:-webmail}"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
restart: unless-stopped
mem_limit: ${PG_MEM_LIMIT:-768m}
stop_grace_period: 60s
logging: *logging
redis:
image: redis:7-alpine
# Leases and the arq queues live here. AOF everysec means a NAS power cut
# costs at most a second of queue state instead of the whole queue.
command:
- redis-server
- --appendonly
- "yes"
- --appendfsync
- everysec
- --maxmemory
- ${REDIS_MAXMEMORY:-96mb}
- --maxmemory-policy
- noeviction
volumes:
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 10
depends_on:
init: { condition: service_completed_successfully }
restart: unless-stopped
mem_limit: ${REDIS_MEM_LIMIT:-160m}
logging: *logging
# Opt-in: `./deploy/webmail.sh up --meili` (or COMPOSE_PROFILES=meili).
# Costs ~700MB-1GB RSS; with SEARCH_BACKEND=pg the stack runs without it.
meilisearch:
image: getmeili/meilisearch:v1.8
profiles: ["meili"]
environment:
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-}
MEILI_ENV: production
MEILI_NO_ANALYTICS: "true"
TZ: ${TZ:-UTC}
volumes:
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/meili:/meili_data
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:7700/health | grep -q available"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
depends_on:
init: { condition: service_completed_successfully }
restart: unless-stopped
mem_limit: ${MEILI_MEM_LIMIT:-1g}
logging: *logging
# --- anti-spam engine (SPEC Appendix C) ------------------------------------
# Not optional: it starts with everything else. Note what does NOT depend on
# it — api, workers and web have no `depends_on: rspamd`. That is deliberate
# and load-bearing: classification is best-effort, delivery is not, so an
# engine that is down, slow or mid-restart must never hold up mail (§C.4).
rspamd:
image: rspamd/rspamd:3.9
# The controller password is an internal secret nobody types; humans reach
# Rspamd through the webmail admin panel, which authenticates with the
# ordinary administrator account. local.d is read-only (the shipped privacy
# defaults), so the generated password is written to override.d at start.
# Starts as root only to write that file — /etc/rspamd is root-owned and the
# image's default user (_rspamd, uid 11333) cannot write it. rspamd then
# drops to _rspamd itself via -u/-g, which is how it normally runs: a root
# master supervising unprivileged workers.
user: root
entrypoint:
- /bin/sh
- -c
- |
mkdir -p /etc/rspamd/override.d
printf 'password = "%s";\nenable_password = "%s";\n' \
"$$RSPAMD_CONTROLLER_PASSWORD" "$$RSPAMD_CONTROLLER_PASSWORD" \
> /etc/rspamd/override.d/worker-controller.inc
exec /usr/bin/rspamd -f -u _rspamd -g _rspamd
environment:
RSPAMD_CONTROLLER_PASSWORD: ${RSPAMD_CONTROLLER_PASSWORD}
TZ: ${TZ:-UTC}
volumes:
- ./docker/rspamd/local.d:/etc/rspamd/local.d:ro
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/rspamd:/var/lib/rspamd
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:11334/ping | grep -qi pong"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
depends_on:
redis-rspamd: { condition: service_healthy }
init: { condition: service_completed_successfully }
restart: unless-stopped
mem_limit: ${RSPAMD_MEM_LIMIT:-256m}
logging: *logging
# Bayes tokens and fuzzy hashes. Separate from the app's redis on purpose
# (§C.2): this data is derived and retrainable, so it trades durability for
# room to grow — RDB snapshots every 5 min, and an eviction policy, where the
# app's redis uses AOF + noeviction because losing a sync lease loses work.
redis-rspamd:
image: redis:7-alpine
command:
- redis-server
- --save
- "300"
- "1"
- --maxmemory
- ${RSPAMD_REDIS_MAXMEMORY:-128mb}
- --maxmemory-policy
- allkeys-lru
volumes:
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/rspamd-redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 10
depends_on:
init: { condition: service_completed_successfully }
restart: unless-stopped
mem_limit: ${RSPAMD_REDIS_MEM_LIMIT:-160m}
logging: *logging
# One-shot schema upgrade: core Alembic head + every plugin's own branch.
# Everything else waits for this to exit 0, so a failed migration stops the
# roll instead of starting code against an old schema.
migrate:
<<: *backend-image
command: ["migrate"]
environment:
<<: *backend-env
ENABLED_PLUGINS: ${ENABLED_PLUGINS:-calendar contacts labels filters}
volumes:
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/blobs:/data/blobs
depends_on:
postgres: { condition: service_healthy }
init: { condition: service_completed_successfully }
restart: "no"
logging: *logging
api:
<<: *backend-image
command: ["api"]
environment: *backend-env
volumes:
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/blobs:/data/blobs
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/healthz', timeout=5).status==200 else 1)\""]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
migrate: { condition: service_completed_successfully }
restart: unless-stopped
mem_limit: ${API_MEM_LIMIT:-512m}
logging: *logging
worker-sync:
<<: *backend-image
command: ["worker-sync"]
environment: *backend-env
volumes:
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/blobs:/data/blobs
# Persistent IMAP IDLE connections; the default 1024 is fine for a personal
# install but this costs nothing and matches the sync-engine v2 sizing.
ulimits:
nofile: { soft: 8192, hard: 8192 }
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
migrate: { condition: service_completed_successfully }
restart: unless-stopped
mem_limit: ${WORKER_SYNC_MEM_LIMIT:-512m}
# Leases are released on SIGTERM (workers/run.py targets <10 s); give it room.
stop_grace_period: 30s
logging: *logging
worker-jobs:
<<: *backend-image
command: ["worker-jobs"]
environment: *backend-env
volumes:
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/blobs:/data/blobs
ulimits:
nofile: { soft: 8192, hard: 8192 }
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
migrate: { condition: service_completed_successfully }
restart: unless-stopped
mem_limit: ${WORKER_JOBS_MEM_LIMIT:-640m}
stop_grace_period: 30s
logging: *logging
web:
image: ${REGISTRY_PREFIX:-webmail}/web:${IMAGE_TAG:-local}
build:
context: .
dockerfile: docker/Dockerfile.frontend
args:
APP_VERSION: ${IMAGE_TAG:-local}
GIT_SHA: ${GIT_SHA:-unknown}
environment:
TZ: ${TZ:-UTC}
ports:
# Bind to a specific NAS interface by setting WEB_BIND / ADMIN_BIND
# (e.g. 192.168.1.185) to keep a port off other networks.
#
# WEB_PORT -> :80, the mail app; /api/admin/* is refused on this one.
# ADMIN_PORT -> :81, the same app with the admin API reachable.
#
# Restrict ADMIN_PORT to the LAN in the NAS firewall — publishing it is
# not what protects it (docs/nas-install.md §5.1). Comment the second
# line out entirely if you would rather reach admin over VPN only.
- "${WEB_BIND:-0.0.0.0}:${WEB_PORT:-8088}:80"
- "${ADMIN_BIND:-0.0.0.0}:${ADMIN_PORT:-8089}:81"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz | grep -q ok"]
interval: 30s
timeout: 5s
retries: 5
depends_on:
api: { condition: service_started }
restart: unless-stopped
mem_limit: ${WEB_MEM_LIMIT:-96m}
logging: *logging
# Nightly logical backup of Postgres into ${DATA_ROOT}/backups, pruned to
# BACKUP_KEEP_DAYS. Point Hyper Backup / HBS3 at ${DATA_ROOT} and this plus the
# blob store is the whole recoverable state (the other half is MASTER_KEY —
# see docs/nas-install.md §"The three things that matter").
backup:
image: postgres:16
user: "999:999"
entrypoint: ["/bin/bash", "/usr/local/bin/backup.sh"]
environment:
PGHOST: postgres
PGUSER: ${POSTGRES_USER:-webmail}
PGPASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: ${POSTGRES_DB:-webmail}
BACKUP_DIR: /backups
BACKUP_AT: ${BACKUP_AT:-03:30}
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
TZ: ${TZ:-UTC}
volumes:
- ${DATA_ROOT:-/NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST}/backups:/backups
- ./docker/backup.sh:/usr/local/bin/backup.sh:ro
depends_on:
postgres: { condition: service_healthy }
init: { condition: service_completed_successfully }
restart: unless-stopped
mem_limit: ${BACKUP_MEM_LIMIT:-256m}
logging: *logging
networks:
default:
name: webmail-prod