DSM reported "invalid interpolation format for x-backend-env.MASTER_KEY" and
"required variable MASTER_KEY is missing a value". Both were mine.
1. DSM's bundled Compose cannot parse `${VAR:?message}`. Those guards are gone;
interpolation now sticks to `${VAR}` and `${VAR:-default}`, the forms every
Compose understands. Bind mounts keep a `${DATA_ROOT:-/DATA_ROOT-is-unset}`
sentinel so a missing config still cannot scatter directories across the
root of the NAS filesystem, and the header says to keep it this way.
2. Config was at docker/.env.prod with a `.env` symlink beside it. Container
Manager did not pick that up. The real file is now `.env` in the project
folder — what every compose front end reads without being told — and
docker/.env.prod becomes a symlink to it. `init` and `require_env` migrate
an existing install in place, keeping its MASTER_KEY.
Also, while testing the migration: `init` only generated secrets when creating
a file from scratch, so a migrated or hand-written .env with an empty
POSTGRES_PASSWORD started Postgres in trust-auth mode. It now tops up empty
secrets — and never touches a MASTER_KEY that already has a value, since
replacing it would make every stored credential undecryptable. doctor flags an
empty password too.
Verified: parses cleanly with no .env at all (the failure DSM hit), migration
preserves an existing key, and `docker compose up -d --wait` from the project
folder brings the stack up healthy and serving.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
324 lines
12 KiB
YAML
324 lines
12 KiB
YAML
# 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)
|
|
#
|
|
# 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/blobs /mnt/backups /mnt/redis /mnt/meili
|
|
chown -R 10001:10001 /mnt/blobs # backend runs as uid 10001
|
|
chown -R 999:999 /mnt/backups # pg_dump runs as postgres (999)
|
|
chown -R 999:999 /mnt/redis # redis runs as uid 999
|
|
chmod 700 /mnt/backups
|
|
echo "[init] data directories ready"
|
|
volumes:
|
|
- ${DATA_ROOT:-/DATA_ROOT-is-unset}/blobs:/mnt/blobs
|
|
- ${DATA_ROOT:-/DATA_ROOT-is-unset}/backups:/mnt/backups
|
|
- ${DATA_ROOT:-/DATA_ROOT-is-unset}/redis:/mnt/redis
|
|
- ${DATA_ROOT:-/DATA_ROOT-is-unset}/meili:/mnt/meili
|
|
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:-/DATA_ROOT-is-unset}/postgres:/var/lib/postgresql/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -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:-/DATA_ROOT-is-unset}/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:-/DATA_ROOT-is-unset}/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
|
|
|
|
# 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:-/DATA_ROOT-is-unset}/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:-/DATA_ROOT-is-unset}/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}
|
|
cpus: ${API_CPUS:-1.0}
|
|
logging: *logging
|
|
|
|
worker-sync:
|
|
<<: *backend-image
|
|
command: ["worker-sync"]
|
|
environment: *backend-env
|
|
volumes:
|
|
- ${DATA_ROOT:-/DATA_ROOT-is-unset}/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}
|
|
cpus: ${WORKER_CPUS:-1.5}
|
|
# 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:-/DATA_ROOT-is-unset}/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}
|
|
cpus: ${WORKER_CPUS:-1.5}
|
|
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:
|
|
# The ONLY published port. Bind to a specific NAS interface by setting
|
|
# WEB_BIND (e.g. 192.168.1.185) to keep it off other networks.
|
|
- "${WEB_BIND:-0.0.0.0}:${WEB_PORT:-8088}:80"
|
|
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:-/DATA_ROOT-is-unset}/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
|