Files
lasuite-messages/compose.yaml
T
jbpenrath 4f3add1414 🔧(frontend) keep node_modules on the host for the native builds
Gradle and Xcode resolve the Capacitor plugins through relative paths
into src/frontend/node_modules, so the named Docker volume that masked
the host tree broke the IDE and the native builds. The dependencies are
now installed in the bind mount (the bootstrap check follows), and the
linked install layout is kept: `cap sync` bakes the real
`.store/<pkg>@<ver>-<hash>` paths into the native project files, and
those hashes derive from the lockfile, so the committed paths are valid
on every machine sharing package-lock.json.

The asset generation script requires packages it does not declare,
which the linked layout no longer hoists for it: they are declared as
devDependencies for that sole purpose.
2026-09-21 15:34:55 +02:00

607 lines
20 KiB
YAML

name: st-messages
# Raise the open-file limit for the Django dev server + import workers. The
# Docker default (1024) is too low: the dev ``runserver`` keeps inbound HTTP
# keep-alive connections open with no idle timeout, so a long UI session polling
# the API (plus large imports opening S3 connections) exhausts FDs and wedges
# the backend with "Too many open files".
x-nofile-ulimits: &nofile-ulimits
nofile:
soft: 262144
hard: 262144
# Shared config for the Celery workers. worker-dev (full stack) and
# worker-dev-light (light stack) differ only in their profiles / depends_on /
# environment; everything else lives here. Note that ``environment`` is a list,
# so the ``<<`` merge does NOT combine it — each service repeats the full list.
x-worker-base: &worker-base
build:
context: src/backend
target: runtime-dev
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
ulimits: *nofile-ulimits
command: ["python", "worker.py", "--loglevel=DEBUG"]
env_file:
- deploy/env/backend.defaults
- deploy/env/backend.local
volumes:
- ./src/backend:/app
- ./data/static:/data/static
mem_limit: 2G
services:
postgresql:
image: postgres:16.6@sha256:557fea37a744d5f4c8faab304b0a90858b53ab119735a88c131fd19dab802f36
ports:
- "8912:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- deploy/env/postgresql.defaults
- deploy/env/postgresql.local
redis:
image: redis:5
ports:
- "8913:6379"
opensearch:
image: opensearchproject/opensearch:2.19.2@sha256:69588c664014fa3d3260ed5dd337f8538fec94790dad00bfbe92301eaaaac240
environment:
- discovery.type=single-node
- bootstrap.memory_lock=true
- "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m"
- "DISABLE_INSTALL_DEMO_CONFIG=true"
- "DISABLE_SECURITY_PLUGIN=true"
# - http.cors.enabled=true
# - "http.cors.allow-origin=/.*/"
ports:
- "8914:9200" # REST API
- "8915:9600" # Performance Analyzer
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9200"]
interval: 1s
timeout: 5s
retries: 60
ulimits:
memlock:
soft: -1 # Set memlock to unlimited (no soft or hard limit)
hard: -1
nofile:
soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536
hard: 65536
mailcatcher:
image: maildev/maildev:2.2.1@sha256:180ef51f65eefebb0e7122d8308813c1fd7bff164bc440ce5a3c2feee167a810
ports:
- "8904:1080"
- "8917:1025"
objectstorage:
# user: ${DOCKER_USER:-1000}
image: rustfs/rustfs:1.0.0-alpha.83@sha256:1cfa82fb394a7c6a4ffc24f5333b52afee608d258503f94b37fcce5cc965c896
environment:
- RUSTFS_ACCESS_KEY=st-messages
- RUSTFS_SECRET_KEY=password
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ADDRESS=0.0.0.0:9000
- RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
- RUSTFS_CORS_ALLOWED_ORIGINS=*
- RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=*
ports:
- "8906:9000"
- "8907:9001"
healthcheck:
test:
[
"CMD",
"sh",
"-c",
"curl -f http://127.0.0.1:9000/health && curl -f http://127.0.0.1:9001/rustfs/console/health",
]
interval: 1s
timeout: 5s
retries: 60
start_period: 0s
volumes:
- objectstorage-data:/data
keycloak:
image: quay.io/keycloak/keycloak:26.7.4@sha256:82a77884f3af238beab1e7afd63b5f530e1b5c0590bd7aa60b40a40463e29b2c
volumes:
- ./src/keycloak/realm.json:/opt/keycloak/data/import/realm.json:ro
- ./src/keycloak/themes/dsfr-2.3.4.jar:/opt/keycloak/providers/keycloak-theme.jar:ro
- ./src/keycloak/bulk-role-membership/bulk-role-membership.jar:/opt/keycloak/providers/bulk-role-membership.jar:ro
environment:
- HOST=http://localhost:8902
- ADMIN_HOST=http://localhost:8902
command:
- start-dev
- --features=preview
- --import-realm
- --proxy-headers=xforwarded
- --http-enabled=true
- --hostname=$${HOST}
- --hostname-admin=$${ADMIN_HOST}
- --http-port=8802
# Expose the management health endpoints (:9000/health/*) so the
# healthcheck below can tell when Keycloak is actually serving.
- --health-enabled=true
env_file:
- deploy/env/keycloak.defaults
- deploy/env/keycloak.local
ports:
- "8902:8802"
# Without a healthcheck, `--wait` / `depends_on: service_started` consider
# Keycloak ready the instant the container process launches — but the JVM is
# still booting + importing the realm for several seconds, so :8902 500s.
# The image ships no curl/wget, so probe the management readiness endpoint
# (:9000/health/ready, 200 only once the realm import + DB are up) with
# bash's /dev/tcp.
healthcheck:
test:
- CMD
- bash
- -c
- "exec 3<>/dev/tcp/localhost/9000; printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3; head -n1 <&3 | grep -q ' 200'"
interval: 3s
timeout: 5s
retries: 40
# During start-up, probe every second (instead of `interval`) so Keycloak
# is detected healthy within ~1s of actually serving. start_period is set
# to comfortably cover a cold JVM boot + realm import so those early
# failures stay in the grace window.
start_period: 25s
start_interval: 1s
depends_on:
- postgresql
backend-base:
build:
context: src/backend
target: runtime-dev
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
volumes:
- ./src/backend:/app
- ./data/static:/data/static
# Dev-only override: live-mount the jmap-email working tree over the
# package installed from PyPI so local source edits propagate without a
# rebuild. The wheel installed at
# ``/venv/lib/${PYTHON_VERSION}/site-packages/jmap_email`` is overlaid
# with the working-tree source. Override ``PYTHON_VERSION`` in the
# environment when the backend's Python floor moves. Comment this mount
# out to run exactly what CI/prod install from PyPI.
- ./src/jmap-email/jmap_email:/venv/lib/${PYTHON_VERSION:-python3.14}/site-packages/jmap_email
ulimits: *nofile-ulimits
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request as u; u.urlopen('http://localhost:8000/__heartbeat__/', timeout=1)"]
interval: 3s
retries: 3
start_period: 10s
backend-dev:
extends: backend-base
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
env_file:
- deploy/env/backend.defaults
- deploy/env/backend.local
ports:
- "8901:8000"
depends_on:
postgresql:
condition: service_healthy
# restart: true
objectstorage:
condition: service_healthy
redis:
condition: service_started
opensearch:
condition: service_healthy
keycloak:
condition: service_healthy
mailcatcher:
condition: service_started
# Lean backend for the default `make start`: same Development config (Redis
# cache + Celery broker, real worker) but WITHOUT OpenSearch, object storage
# or the MTAs — so a plain dev loop boots a handful of containers. Under the
# `light` profile so a bare `docker compose up` (make start-full) skips it;
# `make start` names it explicitly. `backend-dev` is left untouched because
# `make test-back` (docker compose run backend-dev) relies on its OpenSearch /
# object-storage deps to provision those for the search/storage test suites.
backend-dev-light:
extends: backend-base
profiles:
- light
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
# No OpenSearch container in this stack: skip the per-delivery reindex
# enqueue so the worker isn't logging connection errors (search is a
# start-full feature).
- OPENSEARCH_INDEX_THREADS=False
env_file:
- deploy/env/backend.defaults
- deploy/env/backend.local
ports:
- "8901:8000"
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_started
keycloak:
condition: service_healthy
backend-db:
extends: backend-base
profiles:
- tools
environment:
- DJANGO_CONFIGURATION=DevelopmentMinimal
env_file:
- deploy/env/backend.defaults
- deploy/env/backend.local
ports:
- "8901:8000"
depends_on:
postgresql:
condition: service_healthy
# restart: true
backend-uv:
profiles:
- tools
volumes:
- ./src/backend:/app
build:
context: src/backend
target: uv
pull_policy: build
worker-dev:
<<: *worker-base
environment:
- DJANGO_CONFIGURATION=Development
volumes:
- ./src/backend:/app
- ./data/static:/data/static
# Same dev-only jmap-email overlay as backend-base: the worker imports
# the same core code, so it must see the same library source.
- ./src/jmap-email/jmap_email:/venv/lib/${PYTHON_VERSION:-python3.14}/site-packages/jmap_email
depends_on:
- backend-dev
# Worker for the light stack (see backend-dev-light). Needs only the
# broker (Redis) + DB — not the web backend, not OpenSearch. Defined inline
# rather than via ``extends: worker-dev`` because ``extends`` drags in that
# service's ``depends_on: [backend-dev]``, which would pull the full backend
# (and its OpenSearch / object-storage deps) back into the light stack.
worker-dev-light:
<<: *worker-base
profiles:
- light
environment:
- DJANGO_CONFIGURATION=Development
- OPENSEARCH_INDEX_THREADS=False
depends_on:
postgresql:
condition: service_healthy
redis:
condition: service_started
worker-ui:
build:
context: src/backend
target: runtime-dev
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
depends_on:
- redis
environment:
- FLOWER_UNAUTHENTICATED_API=true
- DJANGO_CONFIGURATION=Development
env_file:
- deploy/env/backend.defaults
- deploy/env/backend.local
volumes:
- ./src/backend:/app
# Same dev-only jmap-email overlay as backend-base: flower imports the
# same core code, so it must see the same library source.
- ./src/jmap-email/jmap_email:/venv/lib/${PYTHON_VERSION:-python3.14}/site-packages/jmap_email
ports:
- "8903:8803"
command: celery -A messages.celery_app flower --port=8803
frontend-base:
user: "${DOCKER_USER:-1000}"
build:
context: ./src/frontend
dockerfile: Dockerfile
# Lean image with no node_modules baked in — deps are installed into the
# bind-mounted source tree by `make install-frozen-front`.
target: frontend-dev-base
args:
DOCKER_USER: ${DOCKER_USER:-1000}
frontend-dev:
extends: frontend-base
env_file:
- deploy/env/frontend.defaults
- deploy/env/frontend.local
command: ["npm", "run", "dev"]
volumes:
# node_modules deliberately stays inside this bind mount. Moving it to a
# named volume makes installs faster, but the volume then masks the path
# on the host side, leaving src/frontend/node_modules empty — and the IDE
# (TypeScript, ESLint) and the native mobile builds (Gradle, Xcode/SPM,
# which resolve ../node_modules/@capacitor/*) read it from there. Mirroring
# the tree back out costs more than the install ever saved.
- ./src/frontend/:/home/frontend/
ports:
- "8900:3000"
frontend-tools:
extends: frontend-base
profiles:
- frontend-tools
volumes:
- ./src/backend/core/api/openapi.json:/home/backend/core/api/openapi.json
# `make install-frozen-front` runs here, so it installs straight into the
# host tree that frontend-dev and the native builds consume.
- ./src/frontend/:/home/frontend/
frontend-tools-amd64:
extends: frontend-tools
platform: linux/amd64
# Mobile (Capacitor) web build: like frontend-tools but with the env_file, so
# the NEXT_PUBLIC_* vars Vite inlines at build time are present (frontend-tools
# has none). The native projects (android/, ios/) are written back to the host
# through the mount; the native compile (gradle/xcode) stays a host step.
frontend-mobile:
extends: frontend-base
profiles:
- frontend-tools
# Run in the root group and register the (arbitrary) host uid in /etc/passwd
# before exec'ing the command, so the Capacitor CLI's os.userInfo() call does
# not throw ENOENT under musl. See the chmod in src/frontend/Dockerfile.
# Use DOCKER_UID (uid only) and not DOCKER_USER (uid:gid): appending ":0" to
# the latter yields a malformed "uid:gid:0" spec that drops the root group,
# leaving /etc/passwd read-only and breaking the registration above.
user: "${DOCKER_UID:-1000}:0"
entrypoint:
- /bin/sh
- -c
- 'grep -q ":x:$$(id -u):" /etc/passwd || echo "builder:x:$$(id -u):0::/home/frontend:/bin/sh" >> /etc/passwd; exec "$$@"'
- --
# The Capacitor CLI writes its config under $HOME/.config. HOME is unset for
# the arbitrary uid, so it falls back to "/" and fails with EACCES on
# mkdir /.config. Point it at a writable, non-mounted path (keeps the CLI's
# throwaway config out of the bind-mounted repo).
environment:
HOME: /tmp
env_file:
- deploy/env/frontend.defaults
- deploy/env/frontend.local
volumes:
# `cap sync` runs here and bakes the plugin paths it resolves into the
# committed native project files (Package.swift, capacitor.settings.gradle),
# which Xcode and Gradle then read from the host. Both sides must therefore
# resolve to the same node_modules — hence no masking volume here.
- ./src/frontend/:/home/frontend/
crowdin:
image: crowdin/cli:4.11.0@sha256:9b32eafcfd8ba4b5183bd601bfeb8a65b0d338f2d2fc36df155f4845ee244eff
volumes:
- ".:/app"
env_file:
- deploy/env/crowdin.defaults
- deploy/env/crowdin.local
user: "${DOCKER_USER:-1000}"
working_dir: /app
mta-in:
build:
context: src/mta-in
target: runtime-prod
env_file:
- deploy/env/mta-in.defaults
- deploy/env/mta-in.local
ports:
- "8910:25"
depends_on:
- backend-dev
mta-in-test:
profiles:
- tools
build:
context: src/mta-in
target: runtime-dev
env_file:
- deploy/env/mta-in.defaults
- deploy/env/mta-in.local
environment:
- EXEC_CMD=true
- MDA_API_BASE_URL=http://localhost:8000/api/mail/
- MTA_HOST=localhost
- MTA_PORT=25
- MTA_IMPL=postfix
command: pytest -vvs tests/
volumes:
- ./src/mta-in:/app
mta-in-uv:
profiles:
- tools
volumes:
- ./src/mta-in:/app
build:
context: src/mta-in
target: uv
pull_policy: build
# ---- Pure-Python (aiosmtpd) inbound MTA --------------------------------
# Runs side-by-side with the Postfix-based `mta-in` service on a different
# host port (8920 vs 8910). Both implementations share the same MDA
# contract, env vars, and test suite. Toggle which one is the public-facing
# MTA at the edge by switching the upstream pool.
mta-in-py:
build:
context: src/mta-in
dockerfile: Dockerfile.pymta
target: runtime-distroless-prod
args:
DOCKER_USER: ${DOCKER_USER:-65532}
user: ${DOCKER_USER:-65532}
env_file:
- deploy/env/mta-in.defaults
- deploy/env/mta-in.local
- deploy/env/mta-in-py.defaults
- deploy/env/mta-in-py.local
ports:
- "8920:25"
- "9120:9100" # Prometheus metrics
# Defence-in-depth: pymta needs no on-disk writes at runtime. Read-only
# rootfs + dropped capabilities + no-new-privileges mirror the posture
# a production k8s pod-spec should run with.
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
depends_on:
- backend-dev
mta-in-py-test:
profiles:
- tools
build:
context: src/mta-in
dockerfile: Dockerfile.pymta
target: runtime-dev
args:
DOCKER_USER: ${DOCKER_USER:-65532}
user: ${DOCKER_USER:-65532}
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
env_file:
- deploy/env/mta-in.defaults
- deploy/env/mta-in.local
- deploy/env/mta-in-py.defaults
- deploy/env/mta-in-py.local
environment:
- EXEC_CMD=true
- MDA_API_BASE_URL=http://localhost:8000/api/mail/
- MTA_HOST=localhost
- MTA_PORT=25
- MTA_IMPL=pymta
- MTA_METRICS_URL=http://localhost:9100/metrics
# Self-signed cert baked into the runtime-dev stage, so STARTTLS is
# advertised and tests/test_smtp_protocol.py can exercise the TLS path.
- PYMTA_TLS_CERT_FILE=/certs/pymta-test.crt
- PYMTA_TLS_KEY_FILE=/certs/pymta-test.key
command: pytest -vvs tests/
volumes:
- ./src/mta-in:/app
socks-proxy:
build:
context: src/socks-proxy
target: runtime
env_file:
- deploy/env/socks-proxy.defaults
- deploy/env/socks-proxy.local
ports:
- "8916:1080"
socks-proxy-test:
profiles:
- tools
build:
context: src/socks-proxy/tests
environment:
- SOCKS_PROXY1=user1:pwd1@socks-proxy:1080
- SOCKS_PROXY2=user2:pwd2@socks-proxy:1080
depends_on:
socks-proxy:
condition: service_started
mpa:
build:
context: src/mpa/rspamd
environment:
- RSPAMD_password=password
- PORT=8010
ports:
- "8918:8010"
depends_on:
redis:
condition: service_started
mpa-test:
profiles:
- tools
build:
context: src/mpa/tests
environment:
- RSPAMD_URL=http://mpa:8010/_api
- RSPAMD_AUTH=Bearer password
command: pytest -vvs tests/
volumes:
- ./src/mpa/tests:/app/tests
depends_on:
mpa:
condition: service_started
redis:
condition: service_started
# Self-contained jmap-email package tests. Zero infrastructure
# dependencies (no DB, no opensearch, no redis) — the library has
# no runtime deps. Source is mounted for instant feedback during
# development.
jmap-email-test:
profiles:
- tools
build:
context: src/jmap-email
command: pytest -q tests/
environment:
# Hypothesis examples per `-m fuzz` test. The default matches
# FUZZ_SETTINGS in the suite; CI lowers it on pull requests so the
# job stays a few minutes and runs the full sweep on main.
FUZZ_EXAMPLES: ${FUZZ_EXAMPLES:-10000}
volumes:
- ./src/jmap-email/jmap_email:/app/jmap_email
- ./src/jmap-email/tests:/app/tests
- ./src/jmap-email/pyproject.toml:/app/pyproject.toml
# Hypothesis' example database. Without it the container starts with
# no memory of past failures, so Phase.reuse has nothing to replay
# and an intermittent find stays intermittent.
#
# A named volume rather than a bind mount: a bind mount to a path
# that does not exist yet — and it never does on a fresh clone,
# since .hypothesis is gitignored — is created by Docker as *root*,
# which then blocks `git clean -fdx` and any pytest run against a
# local venv. This is a cache, so nothing needs to read it from the
# host.
- jmap-email-hypothesis:/app/.hypothesis
volumes:
jmap-email-hypothesis:
objectstorage-data: