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.2@sha256:831330513f55695572286e521f94fcd3c7e285250ed5b848090265a33192f669 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 live in the # frontend-node-modules volume, installed 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: - ./src/frontend/:/home/frontend/ # Keep node_modules on a fast Docker volume instead of the (slow, # virtualized) host bind mount above. Writing ~thousands of files to a # bind mount is what made installs take minutes; a named volume lives on # the container filesystem, so `npm ci` runs in ~20s. Populated by # `make install-frozen-front`. - frontend-node-modules:/home/frontend/node_modules ports: - "8900:3000" frontend-tools: extends: frontend-base profiles: - frontend-tools volumes: - ./src/backend/core/api/openapi.json:/home/backend/core/api/openapi.json - ./src/frontend/:/home/frontend/ # Share the same fast node_modules volume as frontend-dev so that # `make install-frozen-front` (which runs here) installs into the volume # that frontend-dev consumes. - frontend-node-modules:/home/frontend/node_modules 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: - ./src/frontend/:/home/frontend/ # Share the same fast node_modules volume as frontend-dev so that # `make install-frozen-front` (which runs here) installs into the volume # that frontend-dev consumes. - frontend-node-modules:/home/frontend/node_modules 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: frontend-node-modules: