Files
LambdaMail/docker-compose.yaml
T
Lucas RayanandClaude Opus 5 ee240ae227 #23: show real data, unify the surfaces and make enrolment scannable
The interface was reporting things that were not true. Several screens were
mockups wired to nothing, and a reader with the console in Portuguese was
served a mixture of two languages.

Fabricated data, removed:

- The outbound queue was two invented jobs held in component state. Retry and
  Freeze only edited that local array, so the screen showed the same two rows
  on every deployment and reported success for actions that never happened.
  "Freeze" named an operation the backend has never implemented. It now reads
  /api/v1/admin/queue, which was already serving the real thing, and offers
  retry and cancel, which exist.
- Every dashboard KPI fell back to an invented constant (1420 received, 890
  sent, 3 queued, 18.4% disk), so an unreachable API produced a dashboard of
  plausible traffic for a server that might have delivered nothing. Two of
  those fields were never returned by the endpoint at all, so the fake disk
  figure was what everyone always saw. DMARC did the same with 1250/98%/97%.
- The domains screen showed thirteen DNS records for example.com, all marked
  VERIFIED, and posted {domain_id: "default-domain"} to endpoints expecting a
  real id. DKIM rotation sends "domain", not "domain_id", so its 400 was
  swallowed and a made-up TXT record displayed as though a key had been
  issued: rotation had never once worked from this screen.
- The TLS panel asserted a Let's Encrypt RSA 4096 certificate and verified
  DANE from static markup, on a deployment currently serving a self-signed
  one. It now reads the cert watcher via /api/v1/admin/tls.
- The readiness scorecard printed "10 / 10 all checks passed" regardless of
  what preflight reported, including failures.
- A message trace that found nothing invented a five-step delivery - TLS 1.3
  accepted, SPF pass, spam score 0.8 - for a message that does not exist.
- Both sidebars showed a fixed admin@lambdamail.local with a hardcoded
  "SUPER_ADMIN (2FA)", claiming a second factor whether or not one existed.
- Settings seeded a "[Boletim]" Sieve rule. Saving posts the whole list, so
  the first edit wrote that invented filter into the account's real script.

The reconcile endpoint itself answered {records_verified: 13, status:
"VERIFIED"} for any domain id, including one that does not exist, without
resolving a name. It now reports what it does, which is record the request.

Surfaces:

The root was a chooser listing webmail and the console side by side, which
asked visitors a question they should not have to answer and advertised cookie
names and audiences to anonymous readers. It now redirects into the mail
client. Accounts that may administer the server get a link to the console from
inside the app - a link to /admin/login, so crossing over still costs the
second factor the admin audience requires - and the console gets a plain link
back, since the webmail session was never given up to get there.

Enrolment:

Both enrolment screens printed the base32 secret as text and left the operator
to type 32 characters into their phone. The enrol endpoint already returned an
otpauth:// URI; it simply was not drawn. Added a shared component that renders
it as a QR locally - the seed is already in the page, and sending it to an
image service would put it in another request log for nothing.

The recovery-code step rendered its own "sign in" button while the form's
submit button stayed visible below it: two identical buttons, and no way to
copy the codes it insisted you save. It is now terminal, with copy and
continue.

Language:

translate() had no interpolation, so "Undo Send ({seconds}s remaining)" showed
the placeholder and a second hardcoded countdown was appended after it. It now
substitutes, leaving an unmatched token visible rather than blanking it.

The pt-BR bundle had lost its diacritics - "Destinatario", "Ultimo erro",
"Acoes" - from the ASCII-source rule being applied to the translations, which
are data. Restored, and the three bundles are back in key parity at 270 each,
with roughly 120 strings that were hardcoded in the source now keyed.

Healthchecks:

web had none, despite being the only service users reach directly: a wedged
Next process kept its container running and Traefik kept routing to it. Added,
using node's own fetch since the image ships no curl. rspamd and clamav gained
the timeouts and start_periods whose absence would fail them during startup.
migrate stays without one and says why: it is a one-shot job, and a probe
would report unhealthy exactly when it has succeeded.

Verified: 36 webmail tests and 41 auth tests against a real Postgres, both new
admin/me cases confirmed to fail without the route (404). lang-lint clean,
production build clean, 0 vulnerabilities, compose validates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:13:53 -03:00

359 lines
15 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}
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}
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}
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}
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}
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}
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
# With the service on two networks Traefik may route via the wrong one
# and return 504. This pins it to the proxy network.
- traefik.docker.network=${COOLIFY_PROXY_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}