Deploy: flatten the prod stack into one root compose file
Synology Container Manager rejects the compose file with "Property include is not allowed": DSM bundles an older Compose than the one that introduced `include:` (v2.20). The root docker-compose.yml existed purely so the NAS GUIs would find production rather than the dev service stack, so using a feature the GUI cannot parse defeated its only purpose. docker/docker-compose.prod.yml is now the root docker-compose.yml itself — one plain, flat file, no indirection. Build contexts go from `..` to `.`, the backup sidecar mounts ./docker/backup.sh, and webmail.sh points at the new path. Nothing else changed: same services, same project name webmail-prod, same guards. The header now says why the file lives at the root and to keep it free of newer compose features, so this does not get "tidied up" back into something DSM refuses. Verified with `docker compose up -d --wait` from the repo root as the GUI invokes it: all services healthy, account created, backup sidecar resolving its script at the new path, and webmail.sh status/doctor/down driving the same stack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
This commit is contained in:
@@ -1,307 +0,0 @@
|
||||
# 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` network.
|
||||
#
|
||||
# Start: ./deploy/webmail.sh up
|
||||
# Manual: docker compose --env-file docker/.env.prod -f docker/docker-compose.prod.yml up -d
|
||||
|
||||
# Deliberately NOT "webmail": docker/docker-compose.yml (the dev service stack)
|
||||
# uses that project name, 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:?set APP_DOMAIN in your .env}"
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-webmail}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in your .env}@postgres:5432/${POSTGRES_DB:-webmail}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
MASTER_KEY: "${MASTER_KEY:?set MASTER_KEY in your .env - generate one with openssl rand -base64 32}"
|
||||
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}/blobs:/mnt/blobs
|
||||
- ${DATA_ROOT}/backups:/mnt/backups
|
||||
- ${DATA_ROOT}/redis:/mnt/redis
|
||||
- ${DATA_ROOT}/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:?set DATA_ROOT in your .env (see docker/.env.prod.example)}/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}/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}/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}/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}/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}/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}/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}/backups:/backups
|
||||
- ./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
|
||||
Reference in New Issue
Block a user