Files
LambdaMail/docker-compose.yaml
T
Lucas RayanandClaude Sonnet 5.5 59363734ff
CI / Lint de idioma (R9) (push) Successful in 4s
CI / Go (protocols) (push) Failing after 5s
CI / TypeScript (auth) (push) Failing after 6s
CI / TypeScript (storage) (push) Successful in 12s
CI / Build (webmail) (push) Successful in 1m38s
CI / Stack completa (push) Skipped
CI / Trivy (dependências, segredos e Dockerfiles) (push) Failing after 1m32s
CI / Imagem (auth) (push) Failing after 11s
CI / Imagem (migrations) (push) Failing after 11s
CI / Imagem (protocols) (push) Failing after 11s
CI / Imagem (rspamd) (push) Failing after 11s
CI / Imagem (storage) (push) Failing after 11s
CI / Imagem (webmail) (push) Failing after 11s
ci: publish images to the Gitea registry, use the gitea context
Release now pushes to git.lucasrguerra.dev.br instead of ghcr.io, tags each
architecture separately (no artifact upload, no gha cache) and joins them with
imagetools. GITHUB_OUTPUT and GITHUB_STEP_SUMMARY stay: they are the runner's
own interface and the GITEA_* equivalents are not set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 08:48:44 -03:00

405 lines
18 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:-git.lucasrguerra.dev.br/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:-git.lucasrguerra.dev.br/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:-git.lucasrguerra.dev.br/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:-git.lucasrguerra.dev.br/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:-git.lucasrguerra.dev.br/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:-git.lucasrguerra.dev.br/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
# Declared so the proxy can discover the port on its own. Without it and
# without a port pinned on the domain, Coolify generates a Traefik service
# pointing at port 80, where nothing listens: every request ends in a 502
# while the container is perfectly healthy. Rebuilding the app on a new
# server is exactly when that pinning gets lost.
expose:
- "3000"
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}