# docker-compose.yml — single-host M0 stack. # Run: docker compose up -d # Then: see M0_VERIFICATION.md for the loadgen smoke test. # # Port conventions (project rule): # - app HTTP services: 8800–8899 (ingestd/routerd/deliverd-fcm/deliverd-telegram/telegramd/admind/loadgen/faketgmd) # - canonical ports stay (5432 postgres, 4222 nats, 6379 redis, # 1883 mqtt, 9090 prometheus, 3000 grafana, etc.) services: # ── Data tier ──────────────────────────────────────────────────── postgres: image: timescale/timescaledb:latest-pg16 environment: POSTGRES_USER: ba POSTGRES_PASSWORD: ba POSTGRES_DB: ba # No host port mapping: a host-local postgres is already on # :5432. The app services reach this one via the docker network # DNS name 'postgres'. Use `docker compose exec postgres psql ...` # to talk to it from the host. expose: ["5432"] volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ba"] interval: 5s timeout: 3s retries: 10 redis: image: redis:7-alpine # No host port mapping: a host-local redis is on :6379. App # services reach this one via docker DNS 'redis'. expose: ["6379"] healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 10 nats: image: nats:2.10-alpine command: ["-js", "-sd", "/data", "-m", "8222"] ports: ["4222:4222", "8222:8222"] # 8222 is the monitoring HTTP volumes: - natsdata:/data healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8222/healthz"] interval: 5s timeout: 3s retries: 20 emqx: image: emqx/emqx:5.10.4 ports: ["1883:1883", "18083:18083"] # MQTT + admin UI volumes: # M4: per-company ACL + auth bootstrap. acl.conf is read on # SIGHUP; the auth CSV is read on first boot. M11 promotes # this to a Postgres-backed authentication chain. - ./deploy/emqx/acl.conf:/opt/emqx/etc/acl.conf:ro - ./deploy/emqx/auth-built-in-db-bootstrap.csv:/opt/emqx/etc/auth-built-in-db-bootstrap.csv:ro # EMQX 5.x prefers env-var config over emqx.conf. The HOCON # path is emqx.conf → base.hocon → cluster.hocon → env vars # (highest precedence). The double-underscore separator in # env-var names maps to nested HOCON keys. environment: # Built-in-db authentication (one chain, password_based, plain) EMQX_AUTHENTICATION__1__BACKEND: "built_in_database" EMQX_AUTHENTICATION__1__MECHANISM: "password_based" EMQX_AUTHENTICATION__1__USER_ID_TYPE: "username" EMQX_AUTHENTICATION__1__PASSWORD_HASH_ALGORITHM__NAME: "plain" EMQX_AUTHENTICATION__1__PASSWORD_HASH_ALGORITHM__SALT_POSITION: "disable" # File-based authorization, default-deny EMQX_AUTHORIZATION__NO_MATCH: "deny" EMQX_AUTHORIZATION__DENY_ACTION: "disconnect" EMQX_AUTHORIZATION__SOURCES__1__TYPE: "file" EMQX_AUTHORIZATION__SOURCES__1__ENABLE: "true" EMQX_AUTHORIZATION__SOURCES__1__PATH: "/opt/emqx/etc/acl.conf" healthcheck: # The original `echo > /dev/tcp/...` ran under sh on # Debian-based EMQX and reported unhealthy even when the # broker was fine. Switch to `bash -c` and a TCP probe. test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/1883"] interval: 10s timeout: 5s retries: 20 clickhouse: image: clickhouse/clickhouse-server:24-alpine ports: ["8123:8123", "9000:9000"] volumes: - chdata:/var/lib/clickhouse ulimits: nofile: { soft: 262144, hard: 262144 } # ── App tier ───────────────────────────────────────────────────── ingestd: build: . command: ["/app/ingestd"] environment: BA_ENV: dev BA_HTTP_ADDR: ":8800" BA_NATS_URL: nats://nats:4222 BA_REDIS_URL: redis://redis:6379/0 BA_POSTGRES_DSN: postgres://ba:ba@postgres:5432/ba?sslmode=disable BA_INGESTD_SOURCES: "acme-001:prom-prod:s3cret-acme,globex-002:grafana:s3cret-globex" BA_INGESTD_RATE_LIMIT_PER_SOURCE: "100" BA_INGESTD_RATE_LIMIT_PER_COMPANY: "10000" # M4: MQTT subscriber. ingestd subscribes to ba/+/+/incoming # with the dedicated `ingestd` user. The auth file lists # this user (password "ingestd-broker-only") so EMQX's # built-in-db authenticates the connection. BA_INGESTD_MQTT_BROKER: "tcp://emqx:1883" BA_INGESTD_MQTT_USERNAME: "ingestd" BA_INGESTD_MQTT_PASSWORD: "ingestd-broker-only" BA_INGESTD_MQTT_SUBSCRIBE: "ba/+/+/incoming" # M5: WebSocket ingest + live tail. TAIL_TOKEN gates the # /v1/tail/ws endpoint; a static token is fine for the # dev path; M11 swaps for JWT. BA_INGESTD_TAIL_TOKEN: "tail-dev-token-please-change-in-prod" BA_INGESTD_MAX_CONCURRENT_PER_IP: "32" BA_INGESTD_DEDUPE_TTL_SECONDS: "300" ports: ["8800:8800"] depends_on: nats: { condition: service_healthy } redis: { condition: service_healthy } postgres: { condition: service_healthy } emqx: { condition: service_healthy } routerd: build: . command: ["/app/routerd"] environment: BA_ENV: dev BA_HTTP_ADDR: ":8801" BA_NATS_URL: nats://nats:4222 BA_POSTGRES_DSN: postgres://ba:ba@postgres:5432/ba?sslmode=disable # M6.5: router-level dedupe collapse window. A burst of # identical alerts is held for up to this many ms, then # a single delivery is fanned out with the final # dedupe_count. A continuous stream re-flushes every # DedupeFlushMs. BA_ROUTERD_DEDUPE_FLUSH_MS: "2000" ports: ["8801:8801"] depends_on: nats: { condition: service_healthy } postgres: { condition: service_healthy } deliverd-fcm: build: . command: ["/app/deliverd-fcm"] environment: BA_ENV: dev BA_HTTP_ADDR: ":8802" BA_NATS_URL: nats://nats:4222 BA_POSTGRES_DSN: postgres://ba:ba@postgres:5432/ba?sslmode=disable BA_FAKECMD_URL: "http://fakefcmd:8820" ports: ["8802:8802"] depends_on: nats: { condition: service_healthy } postgres: { condition: service_healthy } fakefcmd: { condition: service_started } deliverd-telegram: build: . command: ["/app/deliverd-telegram"] environment: BA_ENV: dev BA_HTTP_ADDR: ":8821" BA_NATS_URL: nats://nats:4222 BA_POSTGRES_DSN: postgres://ba:ba@postgres:5432/ba?sslmode=disable BA_TELEGRAM_BOT_TOKEN: "fake-tg-bot-token-acme-001" BA_TELEGRAM_FAKE_URL: "http://faketgmd:8830" ports: ["8821:8821"] depends_on: nats: { condition: service_healthy } postgres: { condition: service_healthy } faketgmd: { condition: service_started } telegramd: build: . command: ["/app/telegramd"] environment: BA_ENV: dev BA_HTTP_ADDR: ":8822" BA_NATS_URL: nats://nats:4222 BA_POSTGRES_DSN: postgres://ba:ba@postgres:5432/ba?sslmode=disable BA_TELEGRAM_FAKE_URL: "http://faketgmd:8830" ports: ["8822:8822"] depends_on: nats: { condition: service_healthy } postgres: { condition: service_healthy } faketgmd: { condition: service_started } faketgmd: build: . command: ["/app/faketgmd", "--addr", ":8830", "--timeout", "5"] ports: ["8830:8830"] fakefcmd: build: . command: ["/app/fakefcmd", "--addr", ":8820"] ports: ["8820:8820"] healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8820/health"] interval: 5s timeout: 3s retries: 10 seed: build: . command: ["/app/seed"] environment: BA_POSTGRES_DSN: postgres://ba:ba@postgres:5432/ba?sslmode=disable BA_MIGRATIONS_DIR: /migrations volumes: - ./migrations:/migrations:ro depends_on: postgres: { condition: service_healthy } admind: build: . command: ["/app/admind"] environment: BA_ENV: dev BA_HTTP_ADDR: ":8803" BA_NATS_URL: nats://nats:4222 BA_POSTGRES_DSN: postgres://ba:ba@postgres:5432/ba?sslmode=disable ports: ["8803:8803"] depends_on: nats: { condition: service_healthy } postgres: { condition: service_healthy } # ── Observability ──────────────────────────────────────────────── prometheus: image: prom/prometheus:latest command: - --config.file=/etc/prometheus/prometheus.yml volumes: - ./deploy/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro ports: ["9090:9090"] depends_on: [ingestd, routerd, deliverd-fcm, deliverd-telegram, telegramd, admind] grafana: image: grafana/grafana:latest # :3000 is held by gogs (git3) on this host. Map to :3001 # on the host, keep :3000 internal. M0 ports convention # says canonical ports stay; the host-port override is a # one-line exception, fully isolated to grafana. ports: ["3001:3000"] environment: GF_SECURITY_ADMIN_USER: admin GF_SECURITY_ADMIN_PASSWORD: admin depends_on: [prometheus] # ── Loadgen (one-shot smoke profile) ──────────────────── loadgen-http: build: . command: - /app/loadgen-http - --target=http://ingestd:8800 - --api-key=acme-001:prom-prod:s3cret-acme - --mode=normal - --rate=50 - --duration=30s - --metrics=:8891 profiles: ["loadgen"] depends_on: ingestd: { condition: service_started } volumes: pgdata: {} natsdata: {} chdata: {}