Um deploy recriava os contentores mas continuava a correr o código antigo. "latest" é uma tag móvel, e o Docker considera satisfeita qualquer tag que já tenha localmente: o compose pedia protocols:latest, o host já tinha uma imagem com esse nome, e a que o Release acabara de publicar nunca era descarregada. O deploy terminava com sucesso, os contentores reiniciavam saudáveis, e nada do que estava a correr mudava. Foi assim que várias correções que passaram no CI nunca chegaram ao site: a rota /api/v1/mail/delete respondia 404 em produção enquanto existia no código e nos testes, e a pasta Reports existia na base de dados - a migração corre a partir da imagem certa - sem existir em nenhuma das interfaces. pull_policy: always nos seis serviços que consomem imagens publicadas. Os que usam imagens de terceiros com versão fixa (postgres:16-alpine, redis:7-alpine, clamav) ficam como estão: aí a tag identifica um conteúdo imutável e voltar a puxar não acrescenta nada. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
398 lines
17 KiB
YAML
398 lines
17 KiB
YAML
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.<domain>
|
|
- 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.<domain> 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}
|