# Default JSON-file log rotation for every service: without it Docker's # json-file driver grows unbounded (audit finding). Applied per-service via # the x-logging anchor. x-logging: &default-logging driver: json-file options: max-size: "50m" max-file: "5" services: postgres: image: postgres:18-alpine container_name: tms-postgres environment: POSTGRES_USER: ${POSTGRES_USER:-tms_user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tms_password} POSTGRES_DB: ${POSTGRES_DB:-tms_db} # Same single-box OOM-competition rationale as the ClickHouse limit: # an unbounded Postgres can starve the scorer under memory pressure. mem_limit: ${POSTGRES_MEM_LIMIT:-1g} ports: - "127.0.0.1:${POSTGRES_PORT:-5433}:5432" volumes: - type: volume source: postgres_data target: /var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-tms_user}"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped logging: *default-logging networks: - tms-network clickhouse: image: clickhouse/clickhouse-server:26.1.3 container_name: tms-clickhouse environment: CLICKHOUSE_DB: ${CLICKHOUSE_DB:-tms_analytics} CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default} CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-} CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 ports: - "127.0.0.1:${CLICKHOUSE_HTTP_PORT:-8123}:8123" - "127.0.0.1:${CLICKHOUSE_NATIVE_PORT:-9000}:9000" volumes: - clickhouse_data:/var/lib/clickhouse - clickhouse_logs:/var/log/clickhouse-server # Caps ClickHouse's own system log tables + server log files, which the # stock image leaves unbounded (they reached ~30 GiB against ~1 GiB of real # data in 15 days on mainnet). Mounted as a SINGLE FILE, not a directory: # bind-mounting over config.d/ would hide the image's own # docker_related_config.xml. If the source path is missing, Docker creates a # DIRECTORY here and ClickHouse silently ignores it (config.d/ is scanned # for *.xml files), so the stack comes up with no limits applied. - ./clickhouse/config.d/log-retention.xml:/etc/clickhouse-server/config.d/log-retention.xml:ro # Disables the query profiler, the largest remaining contributor to # system.trace_log. Separate from the file above because these are user # PROFILE settings: ClickHouse reads them only from users.xml / users.d, # so the same content under config.d/ would parse cleanly and do nothing. # Same single-file rule and same missing-source-becomes-a-directory # failure mode as above. - ./clickhouse/users.d/query-profiler.xml:/etc/clickhouse-server/users.d/query-profiler.xml:ro ulimits: nofile: soft: 262144 hard: 262144 # Without a cap ClickHouse claims most host RAM and OOM-competes with # the node + Postgres on a single box (audit finding). Override via # CLICKHOUSE_MEM_LIMIT for larger hosts. mem_limit: ${CLICKHOUSE_MEM_LIMIT:-4g} healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped logging: *default-logging networks: - tms-network cardano-node: # 11.0.1 is the minimum for the van Rossem PV11 intra-era hard fork # (preprod crossed it at epoch 294, 2026-06-10; mainnet follows). Older # nodes sync to the boundary and stall there. image: ghcr.io/intersectmbo/cardano-node:11.0.1 container_name: tms-cardano-node volumes: - ${CARDANO_CONFIG_DIR:-./cardano-config/preprod}:/config:ro - cardano-node-db:/data - node-ipc:/ipc command: - run - --config - /config/config.json - --topology - /config/topology.json - --database-path - /data - --socket-path - /ipc/node.socket restart: unless-stopped logging: *default-logging networks: - tms-network profiles: - ingestion ogmios: # Pinned per ADR-004 (the parser is tied to the Ogmios v6 schema; a # silent :latest major bump could change message shapes under us). # Upgrade deliberately alongside the cardano-node pin above, re-running # the ADR-004 smoke tests. # v6.14.0 is the newest v6 release as of the node 11.0.1 bump (no Ogmios # release declares node-11 testing yet; v6.14.0 is tested against 10.5.1, # the closest match). Schema stays v6, so the parser contract holds. image: cardanosolutions/ogmios:v6.14.0 container_name: tms-ogmios depends_on: - cardano-node command: - --node-socket - /ipc/node.socket - --node-config - /config/config.json - --host - 0.0.0.0 - --port - "1337" volumes: - ${CARDANO_CONFIG_DIR:-./cardano-config/preprod}:/config:ro - node-ipc:/ipc ports: - "127.0.0.1:1337:1337" restart: unless-stopped logging: *default-logging networks: - tms-network profiles: - ingestion kupo: # Address→transaction index the node/Ogmios cannot provide; it is what the # backend's on-demand historical backfill queries (ingestion/kupo_client.py, # ingestion/address_backfill.py) to find which blocks an address's txs live # in, before re-fetching those blocks at full fidelity via chain-sync. # Pinned like every other image (ADR-004 rationale): a silent major bump # could change the HTTP shape the client parses. Reads the node socket # directly (no Ogmios hop), sharing the node-ipc + config volumes. image: cardanosolutions/kupo:v2.11.0 container_name: tms-kupo depends_on: - cardano-node command: - --node-socket - /ipc/node.socket - --node-config - /config/config.json - --host - 0.0.0.0 - --port - "1442" - --workdir - /kupo-db # What to index and from where. Both are Compose ${...} interpolations, so # they resolve from the shell or the top-level .env ONLY: a per-network # .env.${TMS_ENV} is injected into containers and is never seen here. # KUPO_MATCH defaults to '*' (every address, so any onboarded contract is # answerable). A single narrower pattern (one address or credential) also # works; multiple distinct patterns cannot be expressed through one env var # in this exec-form command, so keep '*' unless you deliberately scope to one. # KUPO_SINCE bounds the one-time index cost: 'origin' indexes the full chain # (needed for an OLD contract's complete history, but large on mainnet), or a # 'slot.header_hash' point to index only from there forward. The start point # is fixed once the DB is built: going deeper later means wiping the volume # and re-indexing. - --match - ${KUPO_MATCH:-*} - --since - ${KUPO_SINCE:-origin} # First-sync speedup: build the DB indexes once at the end of the initial # catch-up instead of per-block. No-op on later restarts. - --defer-db-indexes volumes: - ${CARDANO_CONFIG_DIR:-./cardano-config/preprod}:/config:ro - node-ipc:/ipc - kupo-db:/kupo-db ports: - "127.0.0.1:1442:1442" restart: unless-stopped logging: *default-logging networks: - tms-network profiles: - ingestion # Local SMTP catch-all for magic-link emails. Anything sent to port 1025 # is captured (never forwarded) and viewable in the web UI at :8025. # Opt-in via `--profile mail` (dev/demo, to read captured magic-link emails); # a real deployment instead points the app's SMTP_* env vars at a real SMTP # server and leaves this off. Not started by the default `--profile app` so a # client delivery doesn't run a mail catcher it doesn't need. mailpit: # Pinned like every other image: `latest` made this the one service that # could silently change under a compose pull. Bump deliberately. image: axllent/mailpit:v1.30.4 container_name: tms-mailpit ports: - "127.0.0.1:1025:1025" # SMTP submission - "127.0.0.1:8025:8025" # Web UI restart: unless-stopped networks: - tms-network profiles: - mail # `docker compose --profile app --profile mail up` to include it # Application service (optional - can run on host or in Docker) app: build: context: . dockerfile: backend/Dockerfile args: # Only the Cardano network identifier needs to be baked into the # SPA bundle. Browser auth is the session cookie set by the # magic-link flow — no key is shipped to the client. VITE_NETWORK: ${VITE_NETWORK:-preprod} # Commit this image was built from, stamped onto every score row so a # stored verdict names the code that produced it. Set it in CI or from # the deploy shell: TMS_CODE_VERSION=$(git rev-parse HEAD). The build # arg is the unprefixed CODE_VERSION because that is the name # pydantic-settings binds Settings.CODE_VERSION from. CODE_VERSION: ${TMS_CODE_VERSION:-} container_name: tms-app env_file: - .env # Per-network override (mainnet/preprod/preview/...). Optional: with no # such file present (e.g. a fresh clone) the app falls back to its # built-in defaults (mainnet). Create one from .env.mainnet.example / # .env.preprod.example / .env.preview.example and select it with TMS_ENV. - path: .env.${TMS_ENV:-preprod} required: false environment: - POSTGRES_HOST=postgres - POSTGRES_PORT=5432 - POSTGRES_USER=${POSTGRES_USER:-tms_user} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-tms_password} - POSTGRES_DB=${POSTGRES_DB:-tms_db} - CLICKHOUSE_HOST=clickhouse - CLICKHOUSE_PORT=9000 - CLICKHOUSE_HTTP_PORT=8123 - CLICKHOUSE_USER=${CLICKHOUSE_USER:-default} - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-} - CLICKHOUSE_DB=${CLICKHOUSE_DB:-tms_analytics} # Pin the IN-CONTAINER uvicorn bind to 8000. run.py binds settings.API_PORT, # and the container side of the port mapping, EXPOSE, and the healthcheck # probe are all fixed at 8000. Without this pin a per-network file carrying # API_PORT=8001 (see .env.preview.example) is injected via env_file, uvicorn # listens on 8001, and nothing answers on 8000: the published port is dead # and the container never turns healthy. The HOST-side published port is # still configurable via ${API_PORT} under `ports:` below. - API_PORT=8000 # KUPO_URL is deliberately NOT pinned here. It is an app setting, so it # comes from the env_file layering above (.env, then .env.${TMS_ENV}) and # falls back to the built-in default of "" when neither sets it, which is # what makes backfill opt-in (POST /api/v1/backfill then returns 503). # Trade-off of unpinning: a shell export no longer reaches the container # for this setting or APP_BASE_URL below, because nothing interpolates them # here any more. Set both in .env or .env.${TMS_ENV}, not in the shell. # Set it to http://kupo:1442 when the ingestion-profile kupo service runs, # or to a co-located kupo on a remote node box. An `environment:` entry # here would override env_file AND resolve only from the shell / top-level # .env, so a per-network KUPO_URL would be silently blanked. - RAW_STORE_PATH=/data/raw - RAW_STORE_ENABLED=${RAW_STORE_ENABLED:-true} # Magic-link SMTP defaults — point to the docker-compose mailpit # service by hostname. Customer prod overrides via .env or shell. - SMTP_HOST=${SMTP_HOST:-mailpit} - SMTP_PORT=${SMTP_PORT:-1025} # example.com, not a .local/.test idiom: special-use domains fail # EmailStr and are rejected everywhere TMS validates addresses. - SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL:-noreply@example.com} - SMTP_FROM_NAME=${SMTP_FROM_NAME:-TMS} - SMTP_ENABLED=${SMTP_ENABLED:-true} # APP_BASE_URL is deliberately NOT pinned here, like KUPO_URL above: the # public dashboard URL differs per network, so it has to be settable from # .env.${TMS_ENV}. Its built-in default (http://localhost:8000) is the same # value this line used to interpolate, so an absent setting behaves as # before. Pinning it was a silent footgun: a per-network APP_BASE_URL was # blanked back to localhost and emailed magic links resolved from nowhere. # Client-IP trust for rate limiting + audit logs. The default path is # the append-safe right-most X-Forwarded-For hop, honoured only when # the direct peer is inside TRUSTED_PROXY_CIDRS (set via .env). # TRUSTED_PROXY_CLIENT_IP_HEADER defaults to EMPTY on purpose: set it # to CF-Connecting-IP ONLY when Cloudflare fronts the app. Behind any # other proxy that does not strip the header, a client-supplied # CF-Connecting-IP would be trusted absolutely (rate-limit bucket # rotation, forged audit IPs). Whatever proxy fronts the app MUST # strip or overwrite client-supplied CF-Connecting-IP and # X-Forwarded-For, and TRUSTED_PROXY_CIDRS should be narrowed to the # actual proxy address in production (see .env.example). - TRUSTED_PROXY_ENABLED=${TRUSTED_PROXY_ENABLED:-true} - TRUSTED_PROXY_HOPS=${TRUSTED_PROXY_HOPS:-1} - TRUSTED_PROXY_CLIENT_IP_HEADER=${TRUSTED_PROXY_CLIENT_IP_HEADER:-} # No default: production must set the dashboard origin explicitly # (the app refuses to start with '*' when API keys are configured). # # Stays PINNED, unlike APP_BASE_URL, because the interpolation default # here ('') deliberately differs from the code default ('*'): unpinning # would hand an unset deployment wildcard CORS under TMS_ALLOW_DEV_MODE # instead of the fail-closed empty value. Consequence: set it in .env or # the shell. A per-network CORS_ALLOW_ORIGINS is blanked, and the app then # refuses to start whenever API_KEYS is set. TRUSTED_PROXY_ENABLED is # pinned for the same reason (compose 'true' vs code default False). - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-} # Clustering sidecar wiring. Opt-in: defaults OFF so the app behaves # identically without the module. Set CLUSTERING_ENABLED=true (in .env) # when bringing up `--profile clustering`, or the /api/clustering proxy # returns 503 and the contract_anomaly read-merge stays disabled. The URL # targets the in-network `clustering` service; CLUSTERING_DB must match the # sidecar's CLICKHOUSE_DB (both default to tms_clustering) so the host # reads/purges the same database the sidecar writes. - CLUSTERING_ENABLED=${CLUSTERING_ENABLED:-false} - CLUSTERING_SIDECAR_URL=${CLUSTERING_SIDECAR_URL:-http://clustering:8000} - CLUSTERING_DB=${CLUSTERING_DB:-tms_clustering} # Same key the sidecar expects (CLUSTERING_API_KEY); the proxy forwards it # as X-API-Key so the sidecar can run REQUIRE_AUTH=1. Empty = no auth. - CLUSTERING_SIDECAR_API_KEY=${CLUSTERING_API_KEY:-} mem_limit: ${APP_MEM_LIMIT:-2g} ports: # Bind to loopback only — public exposure happens via Cloudflare Tunnel # (or similar) which terminates TLS in front of the app. # # ${API_PORT} is a Compose interpolation, so it resolves from the shell or # the top-level .env ONLY: to move the published port, set API_PORT there, # not in a per-network .env.. The container side stays 8000 (pinned # under `environment:` above). - "127.0.0.1:${API_PORT:-8000}:8000" volumes: - raw_store_data:/data/raw depends_on: postgres: condition: service_healthy clickhouse: condition: service_healthy # No hard dependency on mailpit: it is opt-in (`--profile mail`) and # magic-link/notification sends are failure-tolerant (an SMTP error logs and # the API still returns a silent 200), so the app must start whether or not a # mail catcher is running. A real deployment points SMTP_* at a real server. restart: unless-stopped logging: *default-logging healthcheck: # Probe /health/ready, not the liveness-only /health: /health/ready 503s # when the ingestion pipeline is DOWN, so a wedged-but-alive process # (breaker open, stuck in recv) reads as unhealthy to orchestrators and # monitors instead of "healthy while ingesting nothing" (prior audit). test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health/ready', timeout=3).status == 200 else 1)\""] interval: 30s timeout: 5s retries: 3 start_period: 30s networks: - tms-network profiles: - app # Use 'docker-compose --profile app up' to include this service # Clustering module: contract-anomaly detection (services/clustering). Runs # only with `--profile clustering`; the API merges its verdicts # (contract_anomaly) when CLUSTERING_ENABLED is set and ignores them otherwise, # so the module is fully opt-in. # # It shares THIS ClickHouse server: its own state lives in tms_clustering, # while transaction/feature reads come from tms_analytics (the same chain data # the core scorers use), so no chain data is duplicated. `app.cli migrate` # creates the tms_clustering schema idempotently on boot. clustering: build: context: ./services/clustering/backend target: ${CLUSTERING_BUILD_TARGET:-runtime} container_name: clustering-sidecar env_file: - .env # Optional per-network override; absent it, built-in defaults apply. - path: .env.${TMS_ENV:-preprod} required: false environment: # Topology: read chain data from tms_analytics, write module state to the # tms_clustering database on the same ClickHouse server. # # CHAIN_SOURCE defaults to host_ch (the integrated topology above). Set # CLUSTERING_CHAIN_SOURCE=blockfrost to instead download an arbitrary # address's history over HTTP (no host_ch auto-feed; writes the downloaded # txs into the sidecar's own tms_clustering tables). Requires # BLOCKFROST_PROJECT_ID; see .env.example. - CHAIN_SOURCE=${CLUSTERING_CHAIN_SOURCE:-host_ch} # Optional secondary history source (host_ch only): automatically # backfills each watched contract's pre-deployment history, bounded per # contract. "blockfrost" downloads into the sidecar's own tables (needs # BLOCKFROST_PROJECT_ID); "kupo" triggers the host's own # POST /api/v1/backfill (needs HOST_API_KEY + host-side KUPO_URL). # Empty disables. See .env.example and services/clustering/docs. - HISTORY_SOURCE=${CLUSTERING_HISTORY_SOURCE:-} - HISTORY_MAX_TXS=${CLUSTERING_HISTORY_MAX_TXS:-500} - HISTORY_MAX_TXS_CEILING=${CLUSTERING_HISTORY_MAX_TXS_CEILING:-5000} # Shortfall gate: skip the backfill when the host already has >= N rows for # the target (0 = off, always top up). See app/config.py history_min_host_txs. - HISTORY_MIN_HOST_TXS=${CLUSTERING_HISTORY_MIN_HOST_TXS:-0} - HOST_API_URL=${CLUSTERING_HOST_API_URL:-http://app:8000} - HOST_API_KEY=${CLUSTERING_HOST_API_KEY:-} - HOST_API_TIMEOUT_SECONDS=${CLUSTERING_HOST_API_TIMEOUT_SECONDS:-30} - BLOCKFROST_PROJECT_ID=${BLOCKFROST_PROJECT_ID:-} - BLOCKFROST_MAX_RPS=${BLOCKFROST_MAX_RPS:-10} - BLOCKFROST_BURST=${BLOCKFROST_BURST:-500} - BLOCKFROST_PAGE_SIZE=${BLOCKFROST_PAGE_SIZE:-100} - CLICKHOUSE_HOST=clickhouse - CLICKHOUSE_HTTP_PORT=8123 - CLICKHOUSE_USER=${CLICKHOUSE_USER:-default} - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-} - CLICKHOUSE_DB=${CLUSTERING_DB:-tms_clustering} - HOST_CLICKHOUSE_DB=${CLICKHOUSE_DB:-tms_analytics} # Driver timeouts. Same env names as the host app but per-service values # (CLUSTERING_-prefixed knobs): clickhouse-connect (HTTP) returns no bytes # until a query finishes, so long fits need 300s where the host's native # driver wants 120s; see services/clustering/docs/host-boundary.md. - CLICKHOUSE_SEND_RECEIVE_TIMEOUT_SECONDS=${CLUSTERING_CLICKHOUSE_SEND_RECEIVE_TIMEOUT_SECONDS:-300} - CLICKHOUSE_CONNECT_TIMEOUT_SECONDS=${CLUSTERING_CLICKHOUSE_CONNECT_TIMEOUT_SECONDS:-10} # CARDANO_NETWORK comes from .env.${TMS_ENV}; one sidecar per network keeps # the engine single-network (no network column on its tables). # Scale / feed knobs (see engine Settings). Window is the CEILING on a # contract's "latest N to cluster on" (and the fit-memory bound); default # is the N a contract gets when none is named; min is the recall floor a # named N is clamped up to. - CLUSTERING_WINDOW_TXS=${CLUSTERING_WINDOW_TXS:-50000} - CLUSTERING_DEFAULT_TARGET_TXS=${CLUSTERING_DEFAULT_TARGET_TXS:-5000} - CLUSTERING_MIN_TARGET_TXS=${CLUSTERING_MIN_TARGET_TXS:-200} - CLUSTERING_DEFAULT_FEATURE_SET=${CLUSTERING_DEFAULT_FEATURE_SET:-shape} - MAX_GRAPH_TXS=${MAX_GRAPH_TXS:-5000} - FEED_ENABLED=${FEED_ENABLED:-true} - FEED_POLL_INTERVAL_SECONDS=${FEED_POLL_INTERVAL_SECONDS:-30} - FEED_MAX_CONTRACTS_PER_TICK=${FEED_MAX_CONTRACTS_PER_TICK:-4} - MODEL_SIGNING_KEYS=${MODEL_SIGNING_KEYS:-} # Auth for the sidecar API. Default off keeps local/demo zero-config; a # network-exposed deployment MUST set CLUSTERING_REQUIRE_AUTH=1 plus # CLUSTERING_API_KEY and MODEL_SIGNING_KEYS, or the sidecar refuses to # start (see app/api/main.lifespan). See .env.example. # The fallback must be a literal boolean: an empty string fails the # sidecar's REQUIRE_AUTH parsing and leaves it in a restart loop. - API_KEY=${CLUSTERING_API_KEY:-} - REQUIRE_AUTH=${CLUSTERING_REQUIRE_AUTH:-false} - CORS_ORIGINS=${CLUSTERING_CORS_ORIGINS:-} - LOG_FORMAT=${LOG_FORMAT:-json} # Re-fits hold the windowed feature matrix + IsolationForest(300) + the # O(n^2) silhouette in memory. The graceful bound is the APP-SIDE window # (CLUSTERING_WINDOW_TXS, the fit-population cap): keep it sized so a re-fit # fits under this limit. mem_limit is the HARD backstop: exceed it and the # kernel OOM-kills the container (then restart:unless-stopped recovers it) # rather than starving ClickHouse (4g) + app (2g) + postgres (1g) on one box. # It bounds blast radius; it does NOT degrade gracefully, so the window cap # is what must keep a fit from reaching it. mem_limit: ${CLUSTERING_MEM_LIMIT:-3g} # Create/upgrade the tms_clustering schema, then serve + run the feed. command: ["sh", "-c", "python -m app.cli migrate && uvicorn app.api.main:app --host 0.0.0.0 --port 8000"] volumes: # Migration source for `app.cli migrate` (the engine's init SQL). - ./services/clustering/clickhouse/init:/init:ro ports: # Loopback only; the host app reaches it in-network for the /api/clustering # proxy. Exposed for debugging/observability. - "127.0.0.1:${CLUSTERING_PORT:-8010}:8000" depends_on: clickhouse: condition: service_healthy restart: unless-stopped logging: *default-logging healthcheck: test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/api/health', timeout=3).status==200 else 1)"] interval: 30s timeout: 5s retries: 3 start_period: 40s networks: - tms-network profiles: - clustering # `docker compose --profile clustering up` to include it volumes: postgres_data: driver: local clickhouse_data: driver: local clickhouse_logs: driver: local raw_store_data: driver: local cardano-node-db: driver: local node-ipc: driver: local kupo-db: driver: local networks: tms-network: driver: bridge