services: # ============================================ # NGINX Load Balancer # ============================================ nginx: image: nginx:alpine ports: - "80:80" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro extra_hosts: - "host.docker.internal:host-gateway" depends_on: - rda networks: - ojuri-network restart: unless-stopped healthcheck: test: ["CMD", "nginx", "-t"] interval: 30s timeout: 10s retries: 3 # ============================================ # Sentinel (operator dashboard) # ============================================ # Static SPA behind its own nginx. Gated behind a profile so a plain # `docker compose up` is unchanged: the repo's default `nginx.conf` # routes `/` to RDA, and adopters fronting Sentinel with their own # ingress do not want a second copy of it running here. Opt in with # `--profile sentinel`, and pair it with a config that routes `/` to # this service — see `deploy/aws/nginx/nginx.conf` for a worked one. sentinel: profiles: ["sentinel"] build: context: ./frontend dockerfile: Dockerfile networks: - ojuri-network restart: unless-stopped healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost/"] interval: 30s timeout: 5s retries: 3 start_period: 10s # ============================================ # RDA Service Replicas (Real-Time Detection Agent) # ============================================ # Scale with RDA_REPLICAS (default 3). NGINX resolves the `rda` DNS # name once at startup and balances across every replica IP Docker # returns — after changing the count, restart nginx so it re-resolves. rda: build: context: . dockerfile: Dockerfile environment: - NODE_ENV=${NODE_ENV:-development} - PORT=3000 - APP_NAME=ojuri-rda - REDIS_HOST=redis - REDIS_PORT=6379 - REDIS_PASSWORD=${REDIS_PASSWORD:-} # Use the INTERNAL listener (advertised as kafka:29092) for # container-to-container traffic. Port 9092 is the EXTERNAL # listener that advertises localhost:9092 for host clients — # which from inside this container resolves to its own loopback # and produces the "Consumer crashed → restart" flap. - KAFKA_BROKERS=kafka:29092 - KAFKA_TOPIC=transactions.completed - KAFKA_BLOCKED_TOPIC=transactions.blocked - AUDIT_PIPELINE=${AUDIT_PIPELINE:-queue} - MODEL_PATH=/app/models/fraud_model.onnx - FRAUD_THRESHOLD=0.65 - ONNX_SESSION_POOL_SIZE=4 - LOG_LEVEL=info - DB_CLIENT=pg - DB_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-fraud_db} # Service-health fan-out. Defaults in HealthService point at # 127.0.0.1 which can't reach sibling containers from inside RDA; # set the Docker-network URLs explicitly. MLA runs natively on # the host (not in compose by design), so its URL uses # host.docker.internal — override with `MLA_HEALTH_URL=` when # MLA is deliberately disabled. - PAA_HEALTH_URL=${PAA_HEALTH_URL:-http://paa-1:9090} - FIA_HEALTH_URL=${FIA_HEALTH_URL:-http://fia:9094} - MLA_HEALTH_URL=${MLA_HEALTH_URL:-http://host.docker.internal:9095} - AUTH_JWT_SECRET=${AUTH_JWT_SECRET} - AUTH_JWT_TTL_SECONDS=${AUTH_JWT_TTL_SECONDS:-28800} - RDA_REQUIRE_API_KEY=${RDA_REQUIRE_API_KEY:-false} # Without this the boot-time production guard sees no allowed origin and # refuses to start, so NODE_ENV=production was unrunnable from this file. - SENTINEL_CORS_ORIGINS=${SENTINEL_CORS_ORIGINS:-} - DEMO_CREDENTIALS_URL=${DEMO_CREDENTIALS_URL:-} - MLA_SERVICE_TOKEN=${MLA_SERVICE_TOKEN:-} - DB_HOST=postgres - DB_PORT=5432 - DB_DATABASE=${POSTGRES_DB:-fraud_db} - DB_USERNAME=${POSTGRES_USER:-postgres} - DB_PASSWORD=${POSTGRES_PASSWORD:-postgres} extra_hosts: # Required on Linux hosts so `host.docker.internal` resolves # for the MLA health probe. macOS Docker Desktop already # provides it implicitly. - "host.docker.internal:host-gateway" volumes: - ./models:/app/models:ro - ./data/training-imports:/app/data/training-imports:ro - ./data/training-uploads:/app/data/training-uploads depends_on: redis: condition: service_healthy kafka: condition: service_healthy postgres: condition: service_healthy db-migrate: condition: service_completed_successfully networks: - ojuri-network restart: unless-stopped deploy: replicas: ${RDA_REPLICAS:-3} resources: limits: memory: 4G # ============================================ # PAA Service (Pattern Analysis Agent) # Singleton Kafka consumer. # # DO NOT scale this service. The graph + velocity state live in # process memory; running a second replica under the same Kafka # consumer group splits the partition assignment, so each replica # sees only a subset of edges and PageRank / Louvain run on a # partial graph. A ring whose members hash to different partitions # becomes invisible. The worker emits an ERROR and a # `paa_group_members` metric > 1 if a second member joins the # `pattern-analysis` group. # ============================================ paa-1: build: context: ./paa-service dockerfile: Dockerfile environment: - NODE_ENV=${NODE_ENV:-development} - APP_NAME=ojuri-paa-1 - METRICS_PORT=9090 - REDIS_HOST=redis - REDIS_PORT=6379 - REDIS_PASSWORD=${REDIS_PASSWORD:-} # Use the INTERNAL listener (advertised as kafka:29092) for # container-to-container traffic. Port 9092 is the EXTERNAL # listener that advertises localhost:9092 for host clients — # which from inside this container resolves to its own loopback # and produces the "Consumer crashed → restart" flap. - KAFKA_BROKERS=kafka:29092 - KAFKA_TOPIC=transactions.completed - KAFKA_CONSUMER_GROUP=pattern-analysis - KAFKA_CLIENT_ID=paa-1 - LOG_LEVEL=info - DB_CLIENT=pg - DB_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-fraud_db} - GRAPH_UPDATE_INTERVAL=300000 - PAGERANK_DAMPING=0.85 - BATCH_SIZE=100 - MAX_GRAPH_NODES=1000000 ports: - "9091:9090" depends_on: redis: condition: service_healthy kafka: condition: service_healthy postgres: condition: service_healthy db-migrate: condition: service_completed_successfully networks: - ojuri-network restart: unless-stopped deploy: replicas: 1 resources: limits: cpus: "2" memory: 8G # ============================================ # FIA Service (Fraud Investigation Agent) # Async LLM-based investigation report generator # ============================================ fia: # FIA carries the Phi-3 LLM weights (~7.6 GB on first run) and the # torch + transformers + accelerate Python stack. Gating it behind # a Compose profile means `docker compose up` is fast for first-time # adopters; opt in with `docker compose --profile fia up` once you # have the disk + bandwidth to download the model. profiles: ["fia"] build: context: ./fia-service dockerfile: Dockerfile environment: # Use the INTERNAL listener (advertised as kafka:29092) for # container-to-container traffic. Port 9092 is the EXTERNAL # listener that advertises localhost:9092 for host clients — # which from inside this container resolves to its own loopback # and produces the "Consumer crashed → restart" flap. - KAFKA_BROKERS=kafka:29092 - KAFKA_BLOCKED_TOPIC=transactions.blocked - KAFKA_CONSUMER_GROUP=ojuri-investigation - KAFKA_AUTO_OFFSET_RESET=earliest - POSTGRES_HOST=postgres - POSTGRES_PORT=5432 - POSTGRES_DB=${POSTGRES_DB:-fraud_db} - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} - LLM_MODEL_NAME=${FIA_LLM_MODEL_NAME:-microsoft/Phi-3-mini-4k-instruct} - LLM_MODEL_PATH=${FIA_LLM_MODEL_PATH:-} - LLM_DEVICE=${FIA_LLM_DEVICE:-auto} - LLM_DTYPE=${FIA_LLM_DTYPE:-auto} - LLM_MAX_NEW_TOKENS=${FIA_LLM_MAX_NEW_TOKENS:-384} - LLM_TEMPERATURE=${FIA_LLM_TEMPERATURE:-0.2} - FIA_FALLBACK_ON_LLM_FAILURE=${FIA_FALLBACK_ON_LLM_FAILURE:-true} # Set to true to skip the Phi-3 load entirely (no 7.6 GB download, # no 15 GB resident model). Reports are served from the # deterministic rule-based path. Useful for demos / CI / low-RAM # hosts. See fia-service/src/llm/phi3_generator.py. - FIA_DISABLE_LLM=${FIA_DISABLE_LLM:-false} - PROMPT_TEMPLATE_VERSION=${FIA_PROMPT_TEMPLATE_VERSION:-v1} - METRICS_PORT=9094 - LOG_LEVEL=${FIA_LOG_LEVEL:-INFO} # Shared with RDA so the same operator JWT works for both APIs. # The HTTP server in handler.py verifies HS256 against this secret. - AUTH_JWT_SECRET=${AUTH_JWT_SECRET} - AUTH_JWT_ISSUER=${AUTH_JWT_ISSUER:-} - AUTH_JWT_AUDIENCE=${AUTH_JWT_AUDIENCE:-} # In Compose the container's loopback is isolated from the host, so # binding 0.0.0.0 is required to expose port 9094 through the # published port mapping. Bare-metal deployments should bind to # 127.0.0.1 (the in-process default) and proxy in front. - FIA_HTTP_BIND=${FIA_HTTP_BIND:-0.0.0.0} # Pin HuggingFace cache into the named volume so the ~8 GB Phi-3 download # survives container recreates. - HF_HOME=/app/.hf-cache # Bound torch's CPU thread pool so it doesn't fight RDA/PAA on the host. - OMP_NUM_THREADS=${FIA_OMP_NUM_THREADS:-2} ports: - "9094:9094" volumes: - fia-hf-cache:/app/.hf-cache - ./fia-service/models:/app/models depends_on: kafka: condition: service_healthy postgres: condition: service_healthy networks: - ojuri-network restart: unless-stopped # Standalone `docker compose up` ignores deploy.resources (those # are Swarm-only). Use top-level mem_limit so the container # actually gets the headroom. Phi-3-mini in fp32 on CPU needs # ~15 GB resident; cap at 16 GiB to leave the host breathing room # but not OOM-kill the load step the way the old 8 GiB cap did. mem_limit: 16g mem_reservation: 6g deploy: resources: limits: cpus: "2" memory: 16G reservations: memory: 6G # ============================================ # MLA Service (Model Learning Agent) # ============================================ # Async drift monitor + retraining pipeline. Not on the prediction # hot path. Gated behind a `mla` profile because adopters often # prefer to run the trainer on a host with GPU access or specific # Python deps. Opt in with `docker compose --profile mla up`. # # When MLA runs IN compose under this profile, set # `MLA_HEALTH_URL=http://mla:9095` in `.env` so the RDA replicas # probe the in-compose service instead of host.docker.internal. mla: profiles: ["mla"] build: context: ./mla-service dockerfile: Dockerfile environment: - POSTGRES_HOST=postgres - POSTGRES_PORT=5432 - POSTGRES_DB=${POSTGRES_DB:-fraud_db} - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} - KAFKA_BROKERS=kafka:29092 - KAFKA_TOPIC=transactions.completed - KAFKA_CONSUMER_GROUP=mla-drift-monitor - METRICS_PORT=9095 - MODEL_OUTPUT_DIR=/app/models - LOG_LEVEL=${MLA_LOG_LEVEL:-INFO} - MLA_SERVICE_TOKEN=${MLA_SERVICE_TOKEN:-} - AUTH_JWT_SECRET=${AUTH_JWT_SECRET} - DRIFT_PSI_THRESHOLD=${DRIFT_PSI_THRESHOLD:-0.25} - DRIFT_F1_THRESHOLD=${DRIFT_F1_THRESHOLD:-0.4} ports: - "9095:9095" volumes: - ./models:/app/models - mla-data:/app/data depends_on: kafka: condition: service_healthy postgres: condition: service_healthy db-migrate: condition: service_completed_successfully networks: - ojuri-network restart: unless-stopped # ============================================ # Redis # ============================================ redis: image: redis:7-alpine command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy volatile-lru ports: - "6380:6379" volumes: - redis-data:/data networks: - ojuri-network restart: unless-stopped healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 # ============================================ # Kafka + Zookeeper # ============================================ zookeeper: image: confluentinc/cp-zookeeper:7.5.0 environment: ZOOKEEPER_CLIENT_PORT: 2181 ZOOKEEPER_TICK_TIME: 2000 volumes: - zookeeper-data:/var/lib/zookeeper/data - zookeeper-logs:/var/lib/zookeeper/log networks: - ojuri-network restart: unless-stopped kafka: image: confluentinc/cp-kafka:7.5.0 depends_on: - zookeeper ports: - "9092:9092" - "29092:29092" environment: KAFKA_BROKER_ID: 1 KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_LISTENERS: INTERNAL://0.0.0.0:29092,EXTERNAL://0.0.0.0:9092 KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://localhost:9092 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_NUM_PARTITIONS: 12 KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" KAFKA_LOG_RETENTION_HOURS: 168 KAFKA_LOG_RETENTION_BYTES: 10737418240 volumes: - kafka-data:/var/lib/kafka/data networks: - ojuri-network restart: unless-stopped healthcheck: test: ["CMD", "kafka-topics", "--bootstrap-server", "localhost:9092", "--list"] interval: 30s timeout: 10s retries: 5 # ============================================ # PostgreSQL # ============================================ postgres: image: postgres:15-alpine environment: POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: ${POSTGRES_DB:-fraud_db} ports: - "5433:5432" volumes: - postgres-data:/var/lib/postgresql/data networks: - ojuri-network restart: unless-stopped healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] interval: 10s timeout: 5s retries: 5 # ============================================ # DB migrate (one-shot) # ============================================ # Runs Knex migrations + seeds against Postgres, then exits 0. # RDA replicas depend on it via service_completed_successfully so a # fresh stack has a fully-migrated schema by the time /v1/predict # accepts requests. On subsequent `docker compose up`, this re-runs # and is a no-op ("Already up to date"). The image is the same RDA # build (it ships the compiled migrations + knexfile); when running # from prebuilt images, knex reads /app/dist/database/{migrations,seeds} # via KNEX_MIGRATIONS_DIR / KNEX_SEEDS_DIR. db-migrate: build: context: . dockerfile: Dockerfile # The normalize step is guarded on the file existing: this compose file # is shipped inside @ojuri/cli and is routinely run against an older # published image via OJURI_VERSION, which has no such file. Requiring # it outright killed db-migrate, and with it the whole stack. # # Invokes the knex CLI directly with node, bypassing the package.json # `db:migrate` script that wraps it in ts-node + dotenv. The production # image runs `npm prune --production` which removes ts-node, and Compose # already injects env vars so dotenv isn't needed. Compiled migrations # live at /app/dist/database/{migrations,seeds} per KNEX_*_DIR below. # The seeds include the SUPER_ADMIN bootstrap that prints a one-time # admin password — surfaced via `docker compose logs db-migrate`. command: - sh - -c - "if [ -f ./dist/database/normalize-migration-names.cli.js ]; then node ./dist/database/normalize-migration-names.cli.js; fi && node ./node_modules/knex/bin/cli.js --knexfile knexfile.js migrate:latest && node ./node_modules/knex/bin/cli.js --knexfile knexfile.js seed:run" environment: - NODE_ENV=${NODE_ENV:-production} - DB_CLIENT=pg - DB_HOST=postgres - DB_PORT=5432 - DB_DATABASE=${POSTGRES_DB:-fraud_db} - DB_USERNAME=${POSTGRES_USER:-postgres} - DB_PASSWORD=${POSTGRES_PASSWORD:-postgres} - KNEX_MIGRATIONS_DIR=/app/dist/database/migrations - KNEX_SEEDS_DIR=/app/dist/database/seeds # Demo rules seed INACTIVE by default (their amount thresholds are # demo props that dominate real traffic). Set true for the demo # dataset walkthrough. See docs/RULES.md. - SEED_DEMO_RULES_ACTIVE=${SEED_DEMO_RULES_ACTIVE:-false} # Unset means the seed migration generates a random password and prints it # once. Supply one when the deployment needs the credential known ahead of # time rather than scraped from migration output. - ADMIN_SEED_PASSWORD=${ADMIN_SEED_PASSWORD:-} # Seeds a shared, read-mostly `demo` account whose password is meant to be # published. Only ever set this on a throwaway public sandbox. - SEED_DEMO_USER=${SEED_DEMO_USER:-false} - DEMO_USER_PASSWORD=${DEMO_USER_PASSWORD:-} # The runtime image ships only dist/ + node_modules — models/ is # mounted, exactly as on the RDA replicas. Without it the # 00_initial_model_version seed silently skips, leaving the model # registry empty and the segment-threshold seed unable to run. # (Restores the mount dropped when #101 merged over #100.) volumes: - ./models:/app/models:ro depends_on: postgres: condition: service_healthy networks: - ojuri-network restart: "no" # ============================================ # Demo traffic seed (one-shot, opt-in) # ============================================ # Sends ~500 realistic transactions (recurring senders plus embedded # fraud patterns: money ring, mule fan-out, VPN, structuring) straight # at the rda service so a fresh stack has a populated dashboard. Opt # in with `docker compose --profile demo run --rm demo-seed`. demo-seed: profiles: ["demo"] image: node:20-alpine command: ["node", "/scripts/demo-traffic.mjs"] environment: - RDA_URL=http://rda:3000 - DEMO_API_KEY=${DEMO_API_KEY:-} - DEMO_TX_COUNT=${DEMO_TX_COUNT:-500} # rda has no compose healthcheck, so the script polls /livez # itself instead of relying on a service_healthy condition. - DEMO_WAIT_FOR_RDA_SECONDS=90 volumes: - ./scripts:/scripts:ro depends_on: rda: condition: service_started networks: - ojuri-network restart: "no" # ============================================ # Monitoring Stack (Optional) # ============================================ prometheus: image: prom/prometheus:v2.47.0 ports: - "9090:9090" volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - prometheus-data:/prometheus command: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.path=/prometheus" - "--web.enable-lifecycle" networks: - ojuri-network restart: unless-stopped grafana: image: grafana/grafana:10.1.0 ports: - "3001:3000" environment: - GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin} - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin} volumes: - grafana-data:/var/lib/grafana depends_on: - prometheus networks: - ojuri-network restart: unless-stopped # ============================================ # Networks # ============================================ networks: ojuri-network: driver: bridge # ============================================ # Volumes # ============================================ volumes: redis-data: postgres-data: kafka-data: zookeeper-data: zookeeper-logs: prometheus-data: grafana-data: fia-hf-cache: mla-data: