# «Контракт-чек» — infrastructure (Step 1). # # Default (`docker compose up`) starts ONLY infra: postgres + redis + rabbitmq # + minio (+ minio-init). Service containers (api, worker-extract, worker-analyze, # bot) and the observability/edge stacks are added behind profiles in later steps # (docs/ARCHITECTURE.md §20). # # Durability posture (§10): quorum-ready. Postgres is configured # wal_level=replica + WAL archiving (replica/PITR-ready). RabbitMQ quorum queues # are declared by the app (core/mq/topology.py) — they replicate the moment a # 3-node cluster is added. Named volumes everywhere; restart: unless-stopped. services: postgres: image: postgres:18-alpine container_name: contract_check-postgres restart: unless-stopped command: - "postgres" - "-c" - "wal_level=replica" - "-c" - "archive_mode=on" - "-c" - "archive_command=test ! -f /walarchive/%f && cp %p /walarchive/%f" environment: POSTGRES_USER: ${POSTGRES_USER:-contract_check} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-contract_check} POSTGRES_DB: ${POSTGRES_DB:-contract_check} volumes: - pgdata:/var/lib/postgresql - pgwal:/walarchive ports: - "15432:5432" healthcheck: test: - CMD-SHELL - "pg_isready -U ${POSTGRES_USER:-contract_check} -d ${POSTGRES_DB:-contract_check}" interval: 5s timeout: 3s retries: 10 redis: image: redis:8-alpine container_name: contract_check-redis restart: unless-stopped command: ["redis-server", "--appendonly", "yes"] volumes: - redisdata:/data ports: - "17379:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 10 rabbitmq: image: rabbitmq:4-management-alpine container_name: contract_check-rabbitmq restart: unless-stopped environment: RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-contract_check} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-contract_check} RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-/} volumes: - rabbitmq:/var/lib/rabbitmq ports: - "5672:5672" # AMQP - "15672:15672" # management UI (http://localhost:15672) healthcheck: test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] interval: 10s timeout: 5s retries: 10 start_period: 15s minio: image: minio/minio:latest container_name: contract_check-minio restart: unless-stopped command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: ${S3_ACCESS_KEY:-contract_check} MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-contract_check} volumes: - minio:/data ports: - "9000:9000" # S3 API - "9001:9001" # console (http://localhost:9001) healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:9000/minio/health/ready"] interval: 10s timeout: 5s retries: 10 start_period: 10s # One-shot: create the bucket + an ILM expiry rule (152-ФЗ retention lever). # Service containers (Step 2+) gate on `service_completed_successfully`. minio-init: image: minio/mc:latest container_name: contract_check-minio-init depends_on: minio: condition: service_healthy entrypoint: /bin/sh command: - -c - | set -e mc alias set local http://minio:9000 "$${MINIO_ROOT_USER:-contract_check}" "$${MINIO_ROOT_PASSWORD:-contract_check}" mc mb --ignore-existing local/${S3_BUCKET:-contract-check-docs} mc anonymous set none local/${S3_BUCKET:-contract-check-docs} || true # Expire raw docs + extracted text after DOC_RETENTION_DAYS (default 7). mc ilm rule add --expire-days ${DOC_RETENTION_DAYS:-7} local/${S3_BUCKET:-contract-check-docs} || true echo "bucket ${S3_BUCKET:-contract-check-docs} ready (ilm expire ${DOC_RETENTION_DAYS:-7}d)" environment: MINIO_ROOT_USER: ${S3_ACCESS_KEY:-contract_check} MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-contract_check} restart: "no" # ── SERVICES (profile: services) ──────────────────────────────────────────── # Core FastAPI service. Runs migrations separately (see deploy docs); assumes # the DB is migrated before accepting traffic via healthcheck delay. api: profiles: ["services"] build: context: . dockerfile: srv/api/Dockerfile container_name: contract_check-api restart: unless-stopped depends_on: postgres: condition: service_healthy rabbitmq: condition: service_healthy minio-init: condition: service_completed_successfully # All app config flows from .env (12-factor; see .env.example for the full # list). New settings need NO compose changes — pydantic Settings reads # them with code-level defaults. `environment:` below only overrides the # values that must point at in-compose hostnames instead of localhost. env_file: - path: .env required: false environment: DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} REDIS_URL: redis://redis:6379/0 RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} S3_ENDPOINT_URL: http://minio:9000 OTEL_SERVICE_NAME: api ports: - "${API_PORT:-8000}:8000" - "${API_METRICS_PORT:-9100}:9100" healthcheck: test: - CMD-SHELL - "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')\"" interval: 10s timeout: 3s retries: 10 start_period: 15s worker-extract: profiles: ["services"] build: context: . dockerfile: srv/worker-extract/Dockerfile container_name: contract_check-worker-extract restart: unless-stopped depends_on: postgres: condition: service_healthy rabbitmq: condition: service_healthy minio-init: condition: service_completed_successfully # App config flows from .env (12-factor; see .env.example). New settings # need NO compose changes — pydantic Settings reads them with code-level # defaults. `environment:` below only overrides the values that must point # at in-compose hostnames instead of localhost. env_file: - path: .env required: false environment: DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} S3_ENDPOINT_URL: http://minio:9000 OTEL_SERVICE_NAME: worker-extract ports: - "9101:9101" worker-analyze: profiles: ["services"] build: context: . dockerfile: srv/worker-analyze/Dockerfile container_name: contract_check-worker-analyze restart: unless-stopped depends_on: postgres: condition: service_healthy rabbitmq: condition: service_healthy minio-init: condition: service_completed_successfully # App config flows from .env (12-factor; see .env.example). New settings # need NO compose changes — pydantic Settings reads them with code-level # defaults. `environment:` below only overrides the values that must point # at in-compose hostnames instead of localhost. env_file: - path: .env required: false environment: DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} S3_ENDPOINT_URL: http://minio:9000 OTEL_SERVICE_NAME: worker-analyze ports: - "9102:9102" worker-prescreen: profiles: ["services"] build: context: . dockerfile: srv/worker-prescreen/Dockerfile container_name: contract_check-worker-prescreen restart: unless-stopped depends_on: postgres: condition: service_healthy rabbitmq: condition: service_healthy minio-init: condition: service_completed_successfully # App config flows from .env (12-factor; see .env.example). New settings # need NO compose changes — pydantic Settings reads them with code-level # defaults. `environment:` below only overrides the values that must point # at in-compose hostnames instead of localhost. env_file: - path: .env required: false environment: DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} S3_ENDPOINT_URL: http://minio:9000 OTEL_SERVICE_NAME: worker-prescreen ports: - "9104:9104" worker-billing: profiles: ["services"] build: context: . dockerfile: srv/worker-billing/Dockerfile container_name: contract_check-worker-billing restart: unless-stopped depends_on: postgres: condition: service_healthy minio-init: condition: service_completed_successfully env_file: - path: .env required: false environment: DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} OTEL_SERVICE_NAME: worker-billing ports: - "9105:9105" worker-notify: profiles: ["services"] build: context: . dockerfile: srv/worker-notify/Dockerfile container_name: contract_check-worker-notify restart: unless-stopped depends_on: rabbitmq: condition: service_healthy # App config flows from .env (12-factor; see .env.example). New settings # need NO compose changes — pydantic Settings reads them with code-level # defaults. `environment:` below only overrides the values that must point # at in-compose hostnames instead of localhost. env_file: - path: .env required: false environment: DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-contract_check}:${POSTGRES_PASSWORD:-contract_check}@postgres:5432/${POSTGRES_DB:-contract_check} RABBITMQ_URL: amqp://${RABBITMQ_USER:-contract_check}:${RABBITMQ_PASS:-contract_check}@rabbitmq:5672/${RABBITMQ_VHOST:-/} S3_ENDPOINT_URL: http://minio:9000 OTEL_SERVICE_NAME: worker-notify ports: - "9103:9103" # Telegram bot adapter (aiogram 3, HTTP-only to api). Per docs/ARCHITECTURE.md §17 # the bot holds no DB/MQ/S3 credentials — it depends on `api` being healthy, # not on the infra containers directly, enforcing the hexagonal boundary even # in dependency ordering. bot: profiles: ["services"] build: context: . dockerfile: srv/bot/Dockerfile container_name: contract_check-bot restart: unless-stopped depends_on: api: condition: service_healthy environment: ENV: ${ENV:-dev} LOG_LEVEL: ${LOG_LEVEL:-INFO} BOT_TOKEN: ${BOT_TOKEN:-} API_URL: ${API_URL:-http://api:8000} BOT_SERVICE_TOKEN: ${BOT_SERVICE_TOKEN:-} # ── EDGE (profile: edge) ────────────────────────────────────────────────── # Reverse proxy + TLS terminator. Listens on 80/443 and forwards # /api/v1/*, /admin/*, /metrics, /healthz, /readyz to the api service. nginx: profiles: ["edge"] # The conf template is BAKED into this image (deploy/nginx/Dockerfile), so # template edits → new image → `up -d --build` recreates and re-renders it. # The VPS override (deploy/vps/) swaps in contract-check-http.conf.template # via a bind mount at the same target path. build: context: ./deploy/nginx dockerfile: Dockerfile container_name: contract_check-nginx restart: unless-stopped depends_on: api: condition: service_healthy ports: - "80:80" - "443:443" volumes: - certbot-data:/etc/letsencrypt:ro - certbot-webroot:/var/www/certbot:ro environment: NGINX_SERVER_NAME: ${NGINX_SERVER_NAME:-localhost} NGINX_ENVSUBST_TEMPLATE_DIR: /etc/nginx/templates NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx/conf.d NGINX_ENVSUBST_TEMPLATE_SUFFIX: .template # Passed to the template so the OpenObserve subpath can be changed in one place. OPENOBSERVE_BASE_URI: ${OPENOBSERVE_BASE_URI:-/openobserve} healthcheck: test: ["CMD", "wget", "-qO-", "--no-check-certificate", "http://localhost/healthz"] interval: 10s timeout: 3s retries: 10 start_period: 10s certbot: profiles: ["edge"] image: certbot/certbot:latest container_name: contract_check-certbot restart: "no" volumes: - certbot-data:/etc/letsencrypt - certbot-webroot:/var/www/certbot entrypoint: /bin/sh command: - -c - | trap exit TERM while :; do certbot renew --webroot-path /var/www/certbot --quiet sleep 12h & wait $${!} done nginx-exporter: profiles: ["edge"] image: nginx/nginx-prometheus-exporter:latest container_name: contract_check-nginx-exporter restart: unless-stopped command: - "-nginx.scrape-uri=http://nginx:80/stub_status" depends_on: nginx: condition: service_healthy ports: - "${NGINX_EXPORTER_PORT:-9113}:9113" # ── OBSERVABILITY ────────────────────────────────────────────────────────── # Two observability stacks are provided; activate exactly one profile. # # profile: observer → OpenObserve (lightweight, single binary). # Use for dev/homelab or when hardware is tight. # profile: obs → Grafana + Prometheus + Loki (mature, heavier). # Use for production-grade visibility. # # App services send OTLP traces/metrics/logs to the endpoint configured in # OTEL_EXPORTER_OTLP_ENDPOINT (.env). Point it at openobserve:5081 for the # lightweight profile, or at your own otel-collector/tempo for the obs profile. # ── LIGHTWEIGHT OBSERVABILITY (profile: observer) ─────────────────────────── openobserve: profiles: ["observer"] image: public.ecr.aws/zinclabs/openobserve:latest container_name: contract_check-openobserve restart: unless-stopped environment: ZO_DATA_DIR: /data ZO_ROOT_USER_EMAIL: ${OPENOBSERVE_ROOT_USER_EMAIL:-root@example.com} ZO_ROOT_USER_PASSWORD: ${OPENOBSERVE_ROOT_USER_PASSWORD:-Complexpass#123} # Keep the lightweight single-node SQLite/local-disk backend by default. # Switch to S3-backed single-node by setting ZO_LOCAL_MODE_STORAGE=s3 and # configuring ZO_S3_* variables (see docs/administration/configuration/). ZO_LOCAL_MODE_STORAGE: ${OPENOBSERVE_LOCAL_MODE_STORAGE:-disk} # Must match the nginx location prefix (deploy/nginx/templates/). ZO_BASE_URI: ${OPENOBSERVE_BASE_URI:-/openobserve} volumes: - openobserve-data:/data ports: - "${OPENOBSERVE_PORT:-5080}:5080" # UI + OTLP/HTTP - "${OPENOBSERVE_GRPC_PORT:-5081}:5081" # OTLP/gRPC deploy: resources: limits: cpus: "${OPENOBSERVE_CPU_LIMIT:-2}" memory: "${OPENOBSERVE_MEMORY_LIMIT:-2G}" reservations: cpus: "${OPENOBSERVE_CPU_RESERVATION:-0.5}" memory: "${OPENOBSERVE_MEMORY_RESERVATION:-512M}" # ── MATURE OBSERVABILITY (profile: obs) ───────────────────────────────────── # Grafana + Loki logs + Prometheus metrics. Promtail scrapes all compose # container logs via the local Docker socket. See deploy/observability/. prometheus: profiles: ["obs"] image: prom/prometheus:latest container_name: contract_check-prometheus restart: unless-stopped command: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.path=/prometheus" - "--storage.tsdb.retention.time=15d" - "--web.console.libraries=/usr/share/prometheus/console_libraries" - "--web.console.templates=/usr/share/prometheus/consoles" - "--web.enable-lifecycle" volumes: - prometheus-data:/prometheus - ./deploy/observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro ports: - "${PROMETHEUS_PORT:-9090}:9090" healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:9090/-/healthy || exit 1"] interval: 10s timeout: 3s retries: 10 start_period: 15s loki: profiles: ["obs"] image: grafana/loki container_name: contract_check-loki restart: unless-stopped command: -config.file=/etc/loki/loki-config.yaml volumes: - loki-data:/loki - ./deploy/observability/loki-config.yaml:/etc/loki/loki-config.yaml:ro ports: - "13100:3100" promtail: profiles: ["obs"] image: grafana/promtail container_name: contract_check-promtail restart: unless-stopped command: -config.file=/etc/promtail/promtail-config.yaml volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./deploy/observability/promtail-config.yaml:/etc/promtail/promtail-config.yaml:ro grafana: profiles: ["obs"] image: grafana/grafana container_name: contract_check-grafana restart: unless-stopped volumes: - grafana-data:/var/lib/grafana - ./deploy/observability/grafana/provisioning:/etc/grafana/provisioning:ro - ./deploy/observability/grafana/dashboards:/var/lib/grafana/dashboards:ro ports: - "${GRAFANA_PORT:-3000}:3000" environment: GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} GF_USERS_ALLOW_SIGN_UP: "false" # When served under a subpath via nginx, Grafana must know the root URL. GF_SERVER_ROOT_URL: "${GRAFANA_ROOT_URL:-http://localhost:3000}/grafana/" GF_SERVER_SERVE_FROM_SUB_PATH: "true" GF_INSTALL_PLUGINS: "" healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || exit 1"] interval: 10s timeout: 3s retries: 10 start_period: 30s volumes: pgdata: pgwal: redisdata: rabbitmq: minio: certbot-data: certbot-webroot: openobserve-data: loki-data: grafana-data: prometheus-data: