x-logging: &default-logging driver: json-file options: { max-size: "10m", max-file: "3" } x-restart: &restart unless-stopped services: # ---------------------------------------------------------------- DB db: image: postgres:16-alpine restart: *restart logging: *default-logging environment: POSTGRES_DB: ${POSTGRES_DB:?required} POSTGRES_USER: ${POSTGRES_USER:?required} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required} # no default: fail fast POSTGRES_INITDB_ARGS: "--data-checksums" command: > postgres -c shared_buffers=256MB -c max_connections=200 -c wal_level=replica volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5 start_period: 30s mem_limit: 1g cpus: 1.0 networks: [backend] # ------------------------------------------------------------- REDIS redis: image: redis:7-alpine restart: *restart logging: *default-logging command: > redis-server --appendonly yes --appendfsync everysec --requirepass ${REDIS_PASSWORD:?required} --maxmemory 384mb --maxmemory-policy noeviction # REDIS_PASSWORD has to exist inside the container for the healthcheck: # $${VAR} expands in the container shell, not in Compose. environment: REDIS_PASSWORD: ${REDIS_PASSWORD} volumes: [redis_data:/data] healthcheck: test: ["CMD-SHELL", "redis-cli --no-auth-warning -a $${REDIS_PASSWORD} ping | grep -q PONG"] interval: 10s timeout: 5s retries: 5 mem_limit: 512m cpus: 0.5 networks: [backend] # ----------------------------------------------------------- MIGRATE # The only service with no healthcheck, deliberately: it is a one-shot job # that applies the migrations and exits. Everything that depends on it waits # on service_completed_successfully, which is a stronger guarantee than a # probe - a health check on a container that is meant to stop would report # unhealthy exactly when it has done its job. migrate: image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/migrations:${IMAGE_TAG:-latest} # "latest" is a moving tag and Docker considers a tag it already holds # locally to be satisfied, so recreating this container silently reused # the cached image and the newly published one was never fetched: the # deploy reported success while the running code stayed put. pull_policy: always restart: "no" logging: *default-logging depends_on: db: { condition: service_healthy } entrypoint: - migrate - -path=/migrations - -database - postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable - up networks: [backend] # ------------------------------------------------------------ RSPAMD rspamd: image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/rspamd:${IMAGE_TAG:-latest} # "latest" is a moving tag and Docker considers a tag it already holds # locally to be satisfied, so recreating this container silently reused # the cached image and the newly published one was never fetched: the # deploy reported success while the running code stayed put. pull_policy: always restart: *restart logging: *default-logging environment: REDIS_PASSWORD: ${REDIS_PASSWORD} volumes: - rspamd_data:/var/lib/rspamd # Rspamd does NOT expand environment variables in its UCL config - a # "${REDIS_PASSWORD}" written into a .conf reaches Redis as that literal # string. The credential is therefore rendered at startup into override.d, # which is a tmpfs so the secret never touches the image or a volume. # Without this, greylisting and ratelimit silently do nothing: both keep # their state in Redis. tmpfs: - /etc/rspamd/override.d:uid=11333,gid=11333,mode=0700 entrypoint: - sh - -c - >- printf 'servers = "redis:6379";\npassword = "%s";\n' "$$REDIS_PASSWORD" > /etc/rspamd/override.d/redis.conf && exec /usr/bin/rspamd -f depends_on: redis: { condition: service_healthy } healthcheck: test: ["CMD-SHELL", "rspamadm control stat >/dev/null 2>&1 || exit 1"] interval: 30s # Without an explicit timeout Docker allows 30s, which overlaps the next # probe; and without a start_period the first checks run while the # worker is still loading maps and mark the container unhealthy. timeout: 10s retries: 3 start_period: 60s mem_limit: 768m networks: [backend, egress] # egress: RBL/DNSBL lookups and fuzzy storage # ------------------------------------------------------------ CLAMAV clamav: # The Debian variant, not the default Alpine one: Alpine is published for # amd64 only, and the deploy target is arm64. Same upstream project, same # clamdcheck.sh healthcheck. image: clamav/clamav:1.5.3-debian restart: *restart logging: *default-logging volumes: [clamav_data:/var/lib/clamav] healthcheck: test: ["CMD-SHELL", "clamdcheck.sh || exit 1"] interval: 60s timeout: 15s # clamd answers slowly while scanning retries: 5 start_period: 300s # the initial signature load is slow mem_limit: 2g # ClamAV genuinely needs this networks: [backend, egress] # egress: freshclam signature updates # -------------------------------------------------------------- AUTH auth: image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/auth:${IMAGE_TAG:-latest} # "latest" is a moving tag and Docker considers a tag it already holds # locally to be satisfied, so recreating this container silently reused # the cached image and the newly published one was never fetched: the # deploy reported success while the running code stayed put. pull_policy: always restart: *restart logging: *default-logging environment: # Drives the Secure attribute on session cookies. Without it the cookies # are issued unsecured, which behind a TLS terminator is a downgrade # waiting to happen. NODE_ENV: ${NODE_ENV:-production} PORT: 3001 DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 JWT_SECRET: ${JWT_SECRET:?required} LAMBDAMAIL_MASTER_KEY: ${LAMBDAMAIL_MASTER_KEY:?required} # KEK for sealed secrets CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-} PRIMARY_MAIL_HOST: ${PRIMARY_MAIL_HOST:?required} PUBLIC_IPV4: ${PUBLIC_IPV4:?required} PUBLIC_IPV6: ${PUBLIC_IPV6:-} depends_on: db: { condition: service_healthy } redis: { condition: service_healthy } migrate: { condition: service_completed_successfully } healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:3001/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 15s timeout: 5s retries: 3 start_period: 20s mem_limit: 512m networks: [backend, egress] # egress: Cloudflare API. Without it DNS sync # fails silently, since backend is internal. # ----------------------------------------------------------- STORAGE storage: image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/storage:${IMAGE_TAG:-latest} # "latest" is a moving tag and Docker considers a tag it already holds # locally to be satisfied, so recreating this container silently reused # the cached image and the newly published one was never fetched: the # deploy reported success while the running code stayed put. pull_policy: always restart: *restart logging: *default-logging environment: NODE_ENV: ${NODE_ENV:-production} PORT: 3002 DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 STORAGE_DRIVER: ${STORAGE_DRIVER:-local} STORAGE_LOCAL_PATH: /var/lambdamail/mails S3_ENDPOINT: ${S3_ENDPOINT:-} S3_BUCKET: ${S3_BUCKET:-} S3_ACCESS_KEY: ${S3_ACCESS_KEY:-} S3_SECRET_KEY: ${S3_SECRET_KEY:-} volumes: [mail_storage:/var/lambdamail/mails] depends_on: db: { condition: service_healthy } redis: { condition: service_healthy } migrate: { condition: service_completed_successfully } healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:3002/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 15s timeout: 5s retries: 3 start_period: 20s mem_limit: 1g cpus: 1.0 networks: [backend, egress] # egress: external S3 when STORAGE_DRIVER=s3 # --------------------------------------------------------- PROTOCOLS protocols: image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/protocols:${IMAGE_TAG:-latest} # "latest" is a moving tag and Docker considers a tag it already holds # locally to be satisfied, so recreating this container silently reused # the cached image and the newly published one was never fetched: the # deploy reported success while the running code stayed put. pull_policy: always restart: *restart logging: *default-logging ports: - "25:25" # SMTP MX - "587:587" # Submission (STARTTLS) - "465:465" # Submission (implicit TLS) - "143:143" # IMAP (STARTTLS) - "993:993" # IMAPS - "110:110" # POP3 (STARTTLS) - "995:995" # POP3S - "4190:4190" # ManageSieve environment: DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 # Shared with the auth service: it mints the webmail sessions and this # service verifies them on /api/v1/mail/*. JWT_SECRET: ${JWT_SECRET:?required} RSPAMD_URL: http://rspamd:11333 CLAMAV_ADDR: clamav:3310 PRIMARY_MAIL_HOST: ${PRIMARY_MAIL_HOST:?required} MAIL_DOMAIN: ${MAIL_DOMAIN:?required} LAMBDAMAIL_MASTER_KEY: ${LAMBDAMAIL_MASTER_KEY:?required} # required for DKIM keys # --- TLS --- TLS_MODE: ${TLS_MODE:-traefik} # traefik | acme TRAEFIK_ACME_DIR: /etc/traefik # the DIRECTORY, not the file (section 8.2) TRAEFIK_ACME_FILENAME: acme.json CERT_POLL_INTERVAL_SECONDS: "60" # --- Policy --- MTA_STS_MODE: ${MTA_STS_MODE:-testing} # promote to enforce once TLS-RPT is clean # DANE requires TLS_MODE=acme; the preflight refuses the combination # with Traefik-managed certificates (section 5.1). OUTBOUND_DANE_ENABLED: ${OUTBOUND_DANE_ENABLED:-false} OUTBOUND_MTA_STS_ENABLED: "true" DNSSEC_RESOLVER: ${DNSSEC_RESOLVER:-1.1.1.1:53} # ARC only earns its keep on a host that forwards mail onward. ARC_SEAL_ENABLED: ${ARC_SEAL_ENABLED:-false} # Smarthost, for providers that block outbound port 25 (section 10.4). # Setting RELAY_HOST also adds the relay to the published SPF record. RELAY_HOST: ${RELAY_HOST:-} RELAY_PORT: ${RELAY_PORT:-587} RELAY_USER: ${RELAY_USER:-} RELAY_PASS: ${RELAY_PASS:-} MAX_MESSAGE_BYTES: "52428800" # --- Self-managed ACME (mode B, section 8.3) --- ACME_EMAIL: ${ACME_EMAIL:-} ACME_DIRECTORY_URL: ${ACME_DIRECTORY_URL:-} # --- DNS automation --- CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-} PUBLIC_IPV4: ${PUBLIC_IPV4:?required} PUBLIC_IPV6: ${PUBLIC_IPV6:-} DNS_SYNC_INTERVAL_SECONDS: "21600" # 6 h drift detection PROTOCOLS_SPOOL_DIR: /var/lambdamail/spool volumes: # Bind-mount the DIRECTORY: Traefik's atomic rename breaks a # file-scoped mount and the watcher would go blind (section 8.2). - ${COOLIFY_PROXY_DIR:-/data/coolify/proxy}:/etc/traefik:ro - protocols_spool:/var/lambdamail/spool depends_on: db: { condition: service_healthy } redis: { condition: service_healthy } migrate: { condition: service_completed_successfully } rspamd: { condition: service_started } clamav: { condition: service_started } healthcheck: test: ["CMD", "/app/lambdamail-protocols", "healthcheck"] interval: 15s timeout: 5s retries: 3 start_period: 30s mem_limit: 1g cpus: 1.5 ulimits: nofile: { soft: 65536, hard: 65536 } networks: [backend, egress] # egress: port 25 delivery, DNSSEC, MTA-STS fetch # -------------------------------------------------------------- WEB web: image: ${IMAGE_NAMESPACE:-ghcr.io/lucasrguerra/lambdamail}/webmail:${IMAGE_TAG:-latest} # "latest" is a moving tag and Docker considers a tag it already holds # locally to be satisfied, so recreating this container silently reused # the cached image and the newly published one was never fetched: the # deploy reported success while the running code stayed put. pull_policy: always restart: *restart logging: *default-logging environment: NODE_ENV: ${NODE_ENV:-production} PORT: 3000 AUTH_SERVICE_URL: http://auth:3001 STORAGE_SERVICE_URL: http://storage:3002 # The mail screens read and send through the protocols service, which # owns the folder, UID, flag and blob logic. PROTOCOLS_SERVICE_URL: http://protocols:8080 NEXT_PUBLIC_APP_URL: https://${WEBMAIL_HOST:?required} # Isolation of the two surfaces (section 14.1). With ADMIN_HOST set the # console answers only on that host; otherwise it lives under # ${WEBMAIL_HOST}/admin. ADMIN_HOST: ${ADMIN_HOST:-} TOTP_ISSUER: ${TOTP_ISSUER:-LambdaMail} MFA_CHALLENGE_TTL_SECONDS: "300" ADMIN_SESSION_TTL_SECONDS: "28800" # 8 h ADMIN_STEPUP_INTERVAL_SECONDS: "900" # re-require TOTP every 15 min USER_SESSION_TTL_SECONDS: "2592000" # 30 d TRUSTED_DEVICE_TTL_SECONDS: "2592000" depends_on: auth: { condition: service_healthy } storage: { condition: service_healthy } protocols: { condition: service_healthy } labels: - traefik.enable=true # Pins Traefik to the proxy network. The container sits on three - # backend, proxy, and the per-app network Coolify adds - and with no # usable value here Traefik picks one arbitrarily. When it picks # "backend" (internal: true, no route to the proxy) every request ends in # a 504 while the container itself is perfectly healthy. # # The name is written out rather than taken from ${COOLIFY_PROXY_NETWORK} # because Coolify escapes $ to $$ when it rewrites labels: the variable # reached Traefik as the literal text "${COOLIFY_PROXY_NETWORK:-coolify}", # which matches no network, so this label silently did nothing. Change it # here if your proxy network is not called "coolify". - traefik.docker.network=coolify # Webmail (/user/*) - traefik.http.routers.lm-web.rule=Host(`${WEBMAIL_HOST}`) - traefik.http.routers.lm-web.tls.certresolver=letsencrypt - traefik.http.services.lm-web.loadbalancer.server.port=3000 # Admin console. Nested fallback: with no ADMIN_HOST the console lives # under the webmail host at /admin. Never use a fictitious host as the # default - Traefik would loop on ACME for it and risk a rate limit. - traefik.http.routers.lm-admin.rule=Host(`${ADMIN_HOST:-${WEBMAIL_HOST}}`) && PathPrefix(`/admin`) - traefik.http.routers.lm-admin.priority=20 - traefik.http.routers.lm-admin.tls.certresolver=letsencrypt # MTA-STS needs a valid certificate on mta-sts. - traefik.http.routers.lm-mtasts.rule=Host(`mta-sts.${MAIL_DOMAIN}`) - traefik.http.routers.lm-mtasts.tls.certresolver=letsencrypt # Autoconfig (Thunderbird) - traefik.http.routers.lm-autoconfig.rule=Host(`autoconfig.${MAIL_DOMAIN}`) - traefik.http.routers.lm-autoconfig.tls.certresolver=letsencrypt # CRITICAL: forces Traefik to issue a certificate for the mail host. # Without this router mail. never appears in acme.json and every # TLS listener comes up without a certificate (section 8.2, trap 2). - traefik.http.routers.lm-mailhost.rule=Host(`${PRIMARY_MAIL_HOST}`) - traefik.http.routers.lm-mailhost.tls.certresolver=letsencrypt # The one service that was never probed, despite being the only one users # reach directly: a wedged Next process kept its container "running" and # Traefik kept routing to it. The image ships no curl or wget, so this uses # node's own fetch, as auth and storage do. healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 15s timeout: 5s retries: 3 start_period: 30s mem_limit: 512m networks: [backend, proxy] volumes: postgres_data: redis_data: mail_storage: rspamd_data: clamav_data: protocols_spool: networks: backend: internal: true # no route to the internet: internal traffic only # Controlled egress: only the services that must reach the internet join it # (auth -> Cloudflare API; protocols -> remote MX, DNS, MTA-STS fetch; # rspamd -> RBLs; clamav -> freshclam; storage -> external S3). egress: driver: bridge proxy: external: true name: ${COOLIFY_PROXY_NETWORK:-coolify}