# 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