2026-09-14 19:37:23 +02:00
# Production stack for a single box (Synology / QNAP / any Docker host).
2026-09-14 19:03:42 +02:00
#
2026-09-14 19:37:23 +02:00
# 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.
2026-09-14 19:03:42 +02:00
#
2026-09-14 19:37:23 +02:00
# Nothing is published except ${WEB_PORT}. Postgres, Redis, Meilisearch and the
# API are reachable only on the internal `webmail` network.
2026-09-14 19:03:42 +02:00
#
2026-09-14 19:37:23 +02:00
# Start: ./deploy/webmail.sh up
# Manual: docker compose --env-file docker/.env.prod -f docker/docker-compose.prod.yml up -d
2026-09-14 19:03:42 +02:00
2026-09-14 19:37:23 +02:00
# 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.
2026-09-14 19:03:42 +02:00
name : webmail-prod
2026-09-14 19:37:23 +02:00
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