diff --git a/docker-compose.yml b/docker-compose.yml index 6048065..2c03e51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,307 @@ -# Production stack, at the repo root so the NAS GUIs find it. +# Production stack for a single box (Synology / QNAP / any Docker host). # -# Synology Container Manager and QNAP Container Station create a project from a -# folder and look for `docker-compose.yml` in it. Pointing them at `docker/` -# would find the DEV service stack (docker/docker-compose.yml: throwaway -# Postgres/Redis/Meilisearch/GreenMail on published dev ports) and start the -# wrong thing entirely — so the production entry point lives here instead. +# 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. # -# Everything real is in docker/docker-compose.prod.yml; `include` resolves that -# file's relative paths against its own directory, so the build context and -# backup.sh still resolve correctly from here. +# Nothing is published except ${WEB_PORT}. Postgres, Redis, Meilisearch and the +# API are reachable only on the internal `webmail` network. # -# Config comes from `.env` in this directory — `deploy/webmail.sh init` creates -# it as a symlink to docker/.env.prod so there is only ever one copy to edit. -# -# GUI: create a project on this folder, name it `webmail-prod` -# CLI: ./deploy/webmail.sh up (preferred: adds backup, health-gated -# updates, rollback and restore) +# 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 -include: - - docker/docker-compose.prod.yml +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 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml deleted file mode 100644 index 2c03e51..0000000 --- a/docker/docker-compose.prod.yml +++ /dev/null @@ -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