x-anubis-base: &anubis-base image: ghcr.io/techarohq/anubis:v1.25.0 restart: always logging: &json-log-limits driver: json-file options: max-size: "10m" max-file: "3" volumes: - ./anubis/botPolicy.yaml:/data/botPolicy.yaml:ro x-internal-hosts: &internal-hosts {} # Docker's internal DNS handles service name resolution services: timescaledb: image: timescale/timescaledb:latest-pg16 restart: always logging: *json-log-limits mem_limit: "4g" memswap_limit: "4g" stop_grace_period: 2m command: ["postgres", "-c", "max_connections=300", "-c", "max_parallel_workers_per_gather=0"] ports: - "${INFRA_BIND_ADDRESS:-127.0.0.1}:5432:5432" environment: POSTGRES_DB: ${POSTGRES_DB:-meshcore} POSTGRES_USER: ${POSTGRES_USER:-meshcore} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required} volumes: - timescaledb_data:/var/lib/postgresql/data - ./backend/src/db/schema/base.sql:/docker-entrypoint-initdb.d/001_schema.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-meshcore} -d ${POSTGRES_DB:-meshcore}"] interval: 30s timeout: 10s retries: 10 start_period: 30s mosquitto: <<: *internal-hosts image: eclipse-mosquitto:2 restart: always logging: *json-log-limits mem_limit: "128m" memswap_limit: "128m" ports: - "${INFRA_BIND_ADDRESS:-127.0.0.1}:9001:9001" healthcheck: test: ["CMD-SHELL", "pgrep mosquitto >/dev/null && nc -z 127.0.0.1 9001"] interval: 30s timeout: 10s retries: 5 start_period: 10s volumes: - ./mosquitto:/mosquitto/config - mosquitto_data:/mosquitto/data - mosquitto_log:/mosquitto/log redis: <<: *internal-hosts image: redis:7-alpine restart: always logging: *json-log-limits mem_limit: "512m" memswap_limit: "512m" command: redis-server --requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD required} environment: REDIS_PASSWORD: ${REDIS_PASSWORD} ports: - "${INFRA_BIND_ADDRESS:-127.0.0.1}:6379:6379" volumes: - redis_data:/data healthcheck: test: ["CMD-SHELL", "redis-cli -a $REDIS_PASSWORD --no-auth-warning ping"] interval: 30s timeout: 10s retries: 5 # Apply additive, versioned migrations once before the application starts. # Base schema creation remains in the database init hook for fresh volumes; # this service keeps existing volumes current while backend startup stays fast. db-migrate: <<: *internal-hosts build: context: . dockerfile: Dockerfile.backend restart: "no" logging: *json-log-limits mem_limit: "256m" memswap_limit: "256m" command: ["node", "dist/tools/migrate.js"] environment: DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} DATABASE_STATEMENT_TIMEOUT_MS: 0 NODE_ENV: production depends_on: timescaledb: condition: service_healthy backend: <<: *internal-hosts build: context: . dockerfile: Dockerfile.backend restart: always logging: *json-log-limits mem_limit: "1g" memswap_limit: "1g" environment: DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} MQTT_BROKER_URL: ${MQTT_BROKER_URL:-ws://mosquitto:9001} MQTT_USERNAME: ${MQTT_USERNAME:-backend} MQTT_PASSWORD: ${MQTT_PASSWORD} REDIS_URL: ${REDIS_URL:-redis://redis:6379} REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD required} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} OPENTOPODATA_API: ${OPENTOPODATA_API:-https://api.opentopodata.org} PORT: ${PORT:-3000} MESHCORE_CHANNEL_SECRETS: ${MESHCORE_CHANNEL_SECRETS:-} OWNER_DATABASE_URL: ${OWNER_DATABASE_URL:-postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${OWNER_POSTGRES_DB:-meshcore_owner_auth}} OWNER_MQTT_USERNAME_MAP: ${OWNER_MQTT_USERNAME_MAP:-} OWNER_AUTHORIZATION_MODE: ${OWNER_AUTHORIZATION_MODE:-shadow} OWNER_ACL_MODE: ${OWNER_ACL_MODE:-shadow} OWNER_ACL_UNMANAGED_USERS: ${OWNER_ACL_UNMANAGED_USERS:-backend,test,test2} OWNER_ACL_ALLOW_EMPTY_USERS: ${OWNER_ACL_ALLOW_EMPTY_USERS:-} OWNER_ACL_SHADOW_PATH: /tmp/meshcore-owner-acl.shadow MOSQUITTO_ACL_PATH: /mosquitto/config/acl MOSQUITTO_CONTAINER_NAME: mosquitto OWNER_COOKIE_SECRET: ${OWNER_COOKIE_SECRET:-} OPERATOR_SITE_TOKEN: ${OPERATOR_SITE_TOKEN:?OPERATOR_SITE_TOKEN required} TRUSTED_PROXY_PEERS: ${TRUSTED_PROXY_PEERS:-172.30.0.10,172.30.0.11,172.30.0.12} ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3003,http://localhost:3004,https://ukmesh.com,https://www.ukmesh.com,https://app.ukmesh.com,https://test.ukmesh.com} RADIO_BOT_URL: ${RADIO_BOT_URL:-} VIEWSHED_ENABLED: ${VIEWSHED_ENABLED:-false} COVERAGE_MODEL_VERSION: ${COVERAGE_MODEL_VERSION:-5} DATABASE_STATEMENT_TIMEOUT_MS: 300000 DATABASE_POOL_MAX: ${DATABASE_POOL_MAX:-20} STATS_DB_QUERY_CONCURRENCY: ${STATS_DB_QUERY_CONCURRENCY:-2} DATABASE_SKIP_SCHEMA_INIT: "true" WS_INITIAL_STATE_ENABLED: "1" WS_INITIAL_STATE_MAX_BYTES: ${WS_INITIAL_STATE_MAX_BYTES:-16777216} MQTT_MAX_PAYLOAD_BYTES: ${MQTT_MAX_PAYLOAD_BYTES:-65536} MQTT_INGEST_CONCURRENCY: ${MQTT_INGEST_CONCURRENCY:-8} MQTT_INGEST_QUEUE_MAX: ${MQTT_INGEST_QUEUE_MAX:-1000} PATH_RESOLVE_WORKERS: ${PATH_RESOLVE_WORKERS:-2} PATH_RESOLVE_BACKGROUND_QUEUE_MAX: ${PATH_RESOLVE_BACKGROUND_QUEUE_MAX:-128} PATH_RESOLVE_INTERACTIVE_QUEUE_MAX: ${PATH_RESOLVE_INTERACTIVE_QUEUE_MAX:-32} PATH_RESOLVE_END_TO_END_TIMEOUT_MS: ${PATH_RESOLVE_END_TO_END_TIMEOUT_MS:-15000} PATH_RESOLVE_UNIQUE_INFLIGHT_MAX: ${PATH_RESOLVE_UNIQUE_INFLIGHT_MAX:-34} PATH_RESOLVE_OBSERVER_VARIANTS_PER_PACKET_MAX: ${PATH_RESOLVE_OBSERVER_VARIANTS_PER_PACKET_MAX:-4} PATH_SINGLE_HISTORY_HOURS: ${PATH_SINGLE_HISTORY_HOURS:-168} PATH_SINGLE_MAX_OBSERVERS: ${PATH_SINGLE_MAX_OBSERVERS:-128} PATH_MULTI_HISTORY_WINDOW_HOURS: ${PATH_MULTI_HISTORY_WINDOW_HOURS:-168} PATH_MULTI_MAX_SCAN_ROWS: ${PATH_MULTI_MAX_SCAN_ROWS:-2048} PATH_MULTI_MAX_OBSERVERS: ${PATH_MULTI_MAX_OBSERVERS:-128} LAZY_PATH_HISTORY_WINDOW_HOURS: ${LAZY_PATH_HISTORY_WINDOW_HOURS:-168} LAZY_PATH_MAX_OBSERVATIONS: ${LAZY_PATH_MAX_OBSERVATIONS:-2048} LAZY_PATH_MAX_OBSERVERS: ${LAZY_PATH_MAX_OBSERVERS:-128} LAZY_PATH_MAX_UNIQUE_HASHES: ${LAZY_PATH_MAX_UNIQUE_HASHES:-64} LAZY_PATH_MAX_CANDIDATE_NODES: ${LAZY_PATH_MAX_CANDIDATE_NODES:-2048} SPAM_MESSAGE_MAX_CANDIDATE_ROWS: ${SPAM_MESSAGE_MAX_CANDIDATE_ROWS:-10000} SPAM_MESSAGE_MAX_MESSAGES: ${SPAM_MESSAGE_MAX_MESSAGES:-2000} SPAM_MESSAGE_MAX_OBSERVERS: ${SPAM_MESSAGE_MAX_OBSERVERS:-32} SPAM_MESSAGE_MAX_NORMALIZED_CHARS: ${SPAM_MESSAGE_MAX_NORMALIZED_CHARS:-512} SPAM_MESSAGE_MAX_INCIDENTS: ${SPAM_MESSAGE_MAX_INCIDENTS:-100} SPAM_MESSAGE_MAX_CANDIDATE_CLUSTERS: ${SPAM_MESSAGE_MAX_CANDIDATE_CLUSTERS:-64} SPAM_MESSAGE_MAX_EVIDENCE_MEMBERS: ${SPAM_MESSAGE_MAX_EVIDENCE_MEMBERS:-256} SPAM_MESSAGE_ANALYSIS_BUDGET_MS: ${SPAM_MESSAGE_ANALYSIS_BUDGET_MS:-5000} SPAM_MESSAGE_DB_TIMEOUT_MS: ${SPAM_MESSAGE_DB_TIMEOUT_MS:-15000} STATS_UNIQUE_INFLIGHT_MAX: ${STATS_UNIQUE_INFLIGHT_MAX:-32} LINK_QUEUE_V3_PRODUCER_ENABLED: ${LINK_QUEUE_V3_PRODUCER_ENABLED:-1} LINK_QUEUE_V3_MAX_JOBS: ${LINK_QUEUE_V3_MAX_JOBS:-5000} LINK_QUEUE_V3_MAX_BYTES: ${LINK_QUEUE_V3_MAX_BYTES:-67108864} LINK_QUEUE_V3_MAX_PAYLOAD_BYTES: ${LINK_QUEUE_V3_MAX_PAYLOAD_BYTES:-32768} PLANNED_COVERAGE_TTL_MS: ${PLANNED_COVERAGE_TTL_MS:-86400000} PLANNED_COVERAGE_QUEUE_MAX: ${PLANNED_COVERAGE_QUEUE_MAX:-100} PLANNED_COVERAGE_MAX_OUTSTANDING_JOBS: ${PLANNED_COVERAGE_MAX_OUTSTANDING_JOBS:-100} PLANNED_COVERAGE_MAX_OUTSTANDING_HANDLES: ${PLANNED_COVERAGE_MAX_OUTSTANDING_HANDLES:-10000} PLANNED_COVERAGE_MAX_HANDLES_PER_JOB: ${PLANNED_COVERAGE_MAX_HANDLES_PER_JOB:-256} MQTT_AUDIT_MAX_OBSERVATIONS_PER_SCAN: ${MQTT_AUDIT_MAX_OBSERVATIONS_PER_SCAN:-4096} MQTT_AUDIT_MAX_NODES_PER_USER: ${MQTT_AUDIT_MAX_NODES_PER_USER:-256} MQTT_AUDIT_RETENTION_DAYS: ${MQTT_AUDIT_RETENTION_DAYS:-30} LINK_REBUILD_LEASE_MS: ${LINK_REBUILD_LEASE_MS:-120000} WS_MAX_PAYLOAD_BYTES: ${WS_MAX_PAYLOAD_BYTES:-65536} WS_MAX_QUEUE_BYTES: ${WS_MAX_QUEUE_BYTES:-8388608} WS_MAX_BUFFERED_BYTES: ${WS_MAX_BUFFERED_BYTES:-33554432} MAINTENANCE_ACTIVE: ${MAINTENANCE_ACTIVE:-0} MAINTENANCE_MESSAGE: ${MAINTENANCE_MESSAGE:-} VIEWSHED_JOB_COOLDOWN_SECONDS: ${VIEWSHED_JOB_COOLDOWN_SECONDS:-300} NODE_ENV: production depends_on: timescaledb: condition: service_healthy db-migrate: condition: service_completed_successfully redis: condition: service_healthy mosquitto: condition: service_healthy ports: - "127.0.0.1:3000:3000" volumes: - mosquitto_log:/mosquitto/log:ro - ./mosquitto:/mosquitto/config - /var/run/docker.sock:/var/run/docker.sock healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/healthz >/dev/null"] interval: 30s timeout: 10s retries: 6 start_period: 30s path-learning-worker: <<: *internal-hosts build: context: . dockerfile: Dockerfile.backend restart: always logging: *json-log-limits mem_limit: "512m" memswap_limit: "512m" command: ["node", "dist/workers/path-learning.js"] environment: DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} REDIS_URL: ${REDIS_URL:-redis://redis:6379} REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD required} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} DATABASE_STATEMENT_TIMEOUT_MS: 300000 DATABASE_SKIP_SCHEMA_INIT: "true" PATH_LEARNING_MAX_TRAINING_PACKETS: ${PATH_LEARNING_MAX_TRAINING_PACKETS:-120000} PATH_LEARNING_MAX_NODES: ${PATH_LEARNING_MAX_NODES:-50000} PATH_LEARNING_MAX_LINKS: ${PATH_LEARNING_MAX_LINKS:-200000} PATH_LEARNING_MAX_AGGREGATE_KEYS: ${PATH_LEARNING_MAX_AGGREGATE_KEYS:-1000000} PATH_LEARNING_RUN_DEADLINE_MS: ${PATH_LEARNING_RUN_DEADLINE_MS:-2700000} NODE_ENV: production depends_on: timescaledb: condition: service_healthy db-migrate: condition: service_completed_successfully redis: condition: service_healthy path-history-worker: <<: *internal-hosts build: context: . dockerfile: Dockerfile.backend restart: always logging: *json-log-limits mem_limit: "512m" memswap_limit: "512m" command: ["node", "dist/workers/path-history.js"] environment: DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} DATABASE_APPLICATION_NAME: meshcore-path-history REDIS_URL: ${REDIS_URL:-redis://redis:6379} REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD required} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} DATABASE_STATEMENT_TIMEOUT_MS: 300000 DATABASE_SKIP_SCHEMA_INIT: "true" PATH_HISTORY_RUN_DEADLINE_MS: ${PATH_HISTORY_RUN_DEADLINE_MS:-7200000} PATH_HISTORY_CONCURRENCY: ${PATH_HISTORY_CONCURRENCY:-2} PATH_HISTORY_SEGMENT_COUNTER_CAPACITY: ${PATH_HISTORY_SEGMENT_COUNTER_CAPACITY:-65536} PATH_MULTI_HISTORY_WINDOW_HOURS: ${PATH_MULTI_HISTORY_WINDOW_HOURS:-168} PATH_MULTI_MAX_SCAN_ROWS: ${PATH_MULTI_MAX_SCAN_ROWS:-2048} PATH_MULTI_MAX_OBSERVERS: ${PATH_MULTI_MAX_OBSERVERS:-128} NODE_ENV: production depends_on: timescaledb: condition: service_healthy db-migrate: condition: service_completed_successfully redis: condition: service_healthy deploy: resources: limits: cpus: "1.0" memory: 512M health-worker: <<: *internal-hosts build: context: . dockerfile: Dockerfile.backend restart: always logging: *json-log-limits mem_limit: "512m" memswap_limit: "512m" command: ["node", "dist/workers/health.js"] environment: DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} REDIS_URL: ${REDIS_URL:-redis://redis:6379} REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD required} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} DATABASE_STATEMENT_TIMEOUT_MS: 300000 DATABASE_SKIP_SCHEMA_INIT: "true" NODE_ENV: production depends_on: timescaledb: condition: service_healthy db-migrate: condition: service_completed_successfully redis: condition: service_healthy synthetic-monitor: <<: *internal-hosts build: context: . dockerfile: Dockerfile.backend restart: always logging: *json-log-limits mem_limit: "256m" memswap_limit: "256m" command: ["node", "dist/workers/synthetic-monitor.js"] environment: DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} DATABASE_SKIP_SCHEMA_INIT: "true" SYNTHETIC_BASE_URL: http://backend:3000 SYNTHETIC_WS_URL: ws://backend:3000 SYNTHETIC_INTERVAL_MS: ${SYNTHETIC_INTERVAL_MS:-60000} SYNTHETIC_TIMEOUT_MS: ${SYNTHETIC_TIMEOUT_MS:-10000} SYNTHETIC_FAILURE_THRESHOLD: ${SYNTHETIC_FAILURE_THRESHOLD:-3} ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-} NODE_ENV: production depends_on: db-migrate: condition: service_completed_successfully backend: condition: service_healthy link-backfill-worker: <<: *internal-hosts build: context: . dockerfile: Dockerfile.backend restart: "no" logging: *json-log-limits mem_limit: "512m" memswap_limit: "512m" command: ["node", "dist/workers/link-backfill.js"] environment: DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} MQTT_BROKER_URL: ${MQTT_BROKER_URL:-ws://mosquitto:9001} REDIS_URL: ${REDIS_URL:-redis://redis:6379} REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD required} MESHCORE_CHANNEL_SECRETS: ${MESHCORE_CHANNEL_SECRETS:-} DATABASE_SKIP_SCHEMA_INIT: "true" NODE_ENV: production depends_on: timescaledb: condition: service_healthy db-migrate: condition: service_completed_successfully redis: condition: service_healthy backend: condition: service_healthy link-worker: condition: service_healthy viewshed-worker: <<: *internal-hosts profiles: [viewshed] build: context: ./viewshed-worker dockerfile: Dockerfile restart: always logging: *json-log-limits mem_limit: "5g" memswap_limit: "5g" environment: DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} REDIS_URL: ${REDIS_URL:-redis://redis:6379} REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD required} SRTM_DIR: /data/srtm WORKER_MODE: viewshed VIEWSHED_ENABLED: ${VIEWSHED_ENABLED:-false} NUM_WORKERS: '1' COVERAGE_MODEL: ${COVERAGE_MODEL:-rf_radial_100m} COVERAGE_MODEL_VERSION: ${COVERAGE_MODEL_VERSION:-5} PLANNED_COVERAGE_TTL_SECONDS: ${PLANNED_COVERAGE_TTL_SECONDS:-86400} PLANNED_COVERAGE_QUEUE_MAX: ${PLANNED_COVERAGE_QUEUE_MAX:-100} volumes: - srtm_data:/data/srtm healthcheck: test: ["CMD-SHELL", "python3 -c \"import os,redis; r=redis.Redis.from_url(os.environ['REDIS_URL'],password=os.environ.get('REDIS_PASSWORD')); raise SystemExit(0 if r.exists('meshcore:viewshed:worker_heartbeat') else 1)\""] interval: 10s timeout: 5s retries: 12 start_period: 20s deploy: resources: limits: cpus: '1.5' memory: '5g' depends_on: timescaledb: condition: service_healthy db-migrate: condition: service_completed_successfully redis: condition: service_healthy link-worker: <<: *internal-hosts build: context: ./viewshed-worker dockerfile: Dockerfile restart: always logging: *json-log-limits mem_limit: "2g" memswap_limit: "2g" cpus: 2.0 environment: DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} REDIS_URL: ${REDIS_URL:-redis://redis:6379} REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD required} RADIO_BOT_URL: ${LINK_WORKER_RADIO_BOT_URL:-} SRTM_DIR: /data/srtm WORKER_MODE: link NUM_WORKERS: '1' LINK_QUEUE_V3_MAX_JOBS: ${LINK_QUEUE_V3_MAX_JOBS:-5000} LINK_QUEUE_V3_MAX_BYTES: ${LINK_QUEUE_V3_MAX_BYTES:-67108864} LINK_QUEUE_V3_MAX_PAYLOAD_BYTES: ${LINK_QUEUE_V3_MAX_PAYLOAD_BYTES:-32768} LINK_QUEUE_V3_MAX_ATTEMPTS: ${LINK_QUEUE_V3_MAX_ATTEMPTS:-5} LINK_QUEUE_V3_LEASE_MS: ${LINK_QUEUE_V3_LEASE_MS:-120000} volumes: - srtm_data:/data/srtm healthcheck: test: ["CMD-SHELL", "python3 -c \"import os,redis; r=redis.Redis.from_url(os.environ['REDIS_URL'],password=os.environ.get('REDIS_PASSWORD')); raise SystemExit(0 if r.exists('meshcore:link:v3:worker_heartbeat') else 1)\""] interval: 10s timeout: 5s retries: 12 start_period: 20s depends_on: timescaledb: condition: service_healthy db-migrate: condition: service_completed_successfully redis: condition: service_healthy # ml-path-learner: # build: # context: ./ml-path-learner # dockerfile: Dockerfile # restart: always # logging: *json-log-limits # cpus: 2.0 # mem_limit: '2g' # environment: # DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore} # GOLD_EXTRACTION_INTERVAL_MINS: '15' # TRAINING_INTERVAL_MINS: '30' # MIN_GOLD_PATHS: '100' # CONFIDENCE_THRESHOLD: '0.85' # ML_RETAIN_VARIANT_RESULT_GENERATIONS: ${ML_RETAIN_VARIANT_RESULT_GENERATIONS:-96} # ML_RETAIN_MODEL_ARTIFACT_GENERATIONS: ${ML_RETAIN_MODEL_ARTIFACT_GENERATIONS:-96} # ML_CLEANUP_GENERATION_BATCH_SIZE: ${ML_CLEANUP_GENERATION_BATCH_SIZE:-24} # depends_on: # timescaledb: # condition: service_healthy app-ukmesh: <<: *internal-hosts build: context: . dockerfile: Dockerfile.app args: VITE_APP_HOSTNAME: app.ukmesh.com VITE_NETWORK: ukmesh VITE_SITE: ukmesh VITE_VIEWSHED_ENABLED: ${VIEWSHED_ENABLED:-false} restart: always logging: *json-log-limits mem_limit: "128m" memswap_limit: "128m" depends_on: backend: condition: service_healthy ports: - "127.0.0.1:3003:80" networks: default: ipv4_address: 172.30.0.10 volumes: - ${TERRAIN_TILES_PATH:-./terrain-tiles}:/terrain-tiles:ro healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null && wget -qO- http://127.0.0.1/readyz >/dev/null"] interval: 30s timeout: 10s retries: 6 start_period: 15s website-ukmesh: <<: *internal-hosts build: context: . dockerfile: Dockerfile.website args: VITE_APP_HOSTNAME: app.ukmesh.com VITE_SITE: ukmesh VITE_VIEWSHED_ENABLED: ${VIEWSHED_ENABLED:-false} restart: always logging: *json-log-limits mem_limit: "128m" memswap_limit: "128m" ports: - "127.0.0.1:3004:80" networks: default: ipv4_address: 172.30.0.11 depends_on: backend: condition: service_healthy healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null && wget -qO- http://127.0.0.1/readyz >/dev/null"] interval: 30s timeout: 10s retries: 6 start_period: 15s mesh-health-check: <<: *internal-hosts build: context: . dockerfile: Dockerfile.mesh-health-check args: MESH_HEALTH_CHECK_REF: ${HEALTHCHECK_SOURCE_REF:-main} restart: always logging: *json-log-limits mem_limit: "512m" memswap_limit: "512m" environment: PORT: "3090" APP_TITLE: ${HEALTHCHECK_APP_TITLE:-UKMesh Health Check} APP_EYEBROW: ${HEALTHCHECK_APP_EYEBROW:-MeshCore Observer Coverage} APP_HEADLINE: ${HEALTHCHECK_APP_HEADLINE:-Check your UK mesh reach.} APP_DESCRIPTION: ${HEALTHCHECK_APP_DESCRIPTION:-Generate a test code, send it to the configured channel, and watch observer coverage build in real time.} EXTERNAL_LINK_URL: ${HEALTHCHECK_EXTERNAL_LINK_URL:-https://ukmesh.com} EXTERNAL_LINK_LABEL: ${HEALTHCHECK_EXTERNAL_LINK_LABEL:-UKMesh} LOG_LEVEL: ${HEALTHCHECK_LOG_LEVEL:-info} TRUST_PROXY: "1" MQTT_HOST: mosquitto MQTT_PORT: "9001" MQTT_USERNAME: ${MQTT_USERNAME:-backend} MQTT_PASSWORD: ${MQTT_PASSWORD} MQTT_TOPIC: ${HEALTHCHECK_MQTT_TOPIC:-meshcore/+/+/packets,meshcore/+/+/status,meshcore/+/+/internal} MQTT_TRANSPORT: websockets MQTT_WS_PATH: / MQTT_TLS: "false" DASH_BROKER_HOST: ${HEALTHCHECK_DASH_BROKER_HOST:-mqtt.ukmesh.com} TEST_CHANNEL_NAME: ${HEALTHCHECK_TEST_CHANNEL_NAME:-ukmeshtest} TEST_CHANNEL_SECRET: ${HEALTHCHECK_TEST_CHANNEL_SECRET:-} TEST_CHANNEL_SECRET_SOURCE_NAME: ${HEALTHCHECK_TEST_CHANNEL_SECRET_SOURCE_NAME:-test} TEST_CHANNEL_HASH: ${HEALTHCHECK_TEST_CHANNEL_HASH:-} MESHCORE_CHANNEL_SECRETS: ${MESHCORE_CHANNEL_SECRETS:-} SESSION_TTL_SECONDS: ${HEALTHCHECK_SESSION_TTL_SECONDS:-600} RESULT_RETENTION_SECONDS: ${HEALTHCHECK_RESULT_RETENTION_SECONDS:-604800} MAX_USES_PER_CODE: ${HEALTHCHECK_MAX_USES_PER_CODE:-3} KNOWN_OBSERVERS: ${HEALTHCHECK_KNOWN_OBSERVERS:-} OBSERVER_ACTIVE_WINDOW_SECONDS: ${HEALTHCHECK_OBSERVER_ACTIVE_WINDOW_SECONDS:-900} OBSERVER_RETENTION_SECONDS: ${HEALTHCHECK_OBSERVER_RETENTION_SECONDS:-14400} OBSERVERS_FILE: /app/data/observer.json RESULTS_FILE: /app/data/session-results.json REGIONS_FILE: ${HEALTHCHECK_REGIONS_FILE:-regions/uk.geojson} TURNSTILE_ENABLED: ${HEALTHCHECK_TURNSTILE_ENABLED:-false} TURNSTILE_SITE_KEY: ${HEALTHCHECK_TURNSTILE_SITE_KEY:-} TURNSTILE_SECRET_KEY: ${HEALTHCHECK_TURNSTILE_SECRET_KEY:-} depends_on: mosquitto: condition: service_healthy ports: - "127.0.0.1:3090:3090" volumes: - mesh_health_check_data:/app/data - ./healthcheck-overrides/index.html:/app/public/index.html:ro - ./healthcheck-overrides/share.html:/app/public/share.html:ro - ./healthcheck-overrides/sw.js:/app/public/sw.js:ro - ./healthcheck-overrides/ukmesh-theme.css:/app/public/ukmesh-theme.css:ro healthcheck: # The upstream app has no /healthz route; its SPA fallback returns 200 # there even when the API is unavailable. Probe a real JSON endpoint. test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3090/api/bootstrap').then(r=>process.exit(r.ok && (r.headers.get('content-type') || '').includes('application/json') ? 0 : 1)).catch(()=>process.exit(1))\""] interval: 30s timeout: 10s retries: 6 start_period: 30s website-dev: <<: *internal-hosts build: context: . dockerfile: Dockerfile.website args: VITE_APP_HOSTNAME: ${VITE_DEV_APP_HOSTNAME:-app-dev.ukmesh.com} VITE_NETWORK: test VITE_SITE: dev VITE_OBSERVER_ID: ${VITE_DEV_OBSERVER_ID:-} VITE_SITE_DISPLAY_NAME: ${VITE_DEV_SITE_DISPLAY_NAME:-UK Mesh Test} VITE_SITE_FOOTER_NAME: ${VITE_DEV_SITE_FOOTER_NAME:-UK Mesh Test} VITE_SITE_APP_URL: ${VITE_DEV_SITE_APP_URL:-https://test.ukmesh.com} VITE_SITE_HOME_URL: ${VITE_DEV_SITE_HOME_URL:-https://test.ukmesh.com} VITE_VIEWSHED_ENABLED: ${VIEWSHED_ENABLED:-false} restart: always profiles: [dev] logging: *json-log-limits mem_limit: "128m" memswap_limit: "128m" ports: - "127.0.0.1:3006:80" networks: default: ipv4_address: 172.30.0.12 depends_on: backend: condition: service_healthy healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ >/dev/null"] interval: 30s timeout: 10s retries: 6 start_period: 15s # ── Anubis bot protection ───────────────────────────────────────────────────── # One sidecar per public HTTP service. Cloudflare Tunnel routes to these # containers (port 8923) instead of the origin services directly. # MQTT (mosquitto:9001) is intentionally excluded — non-browser clients. # Dev services are intentionally excluded — low value. anubis-website-ukmesh: <<: [*anubis-base, *internal-hosts] mem_limit: "128m" memswap_limit: "128m" environment: ED25519_PRIVATE_KEY_HEX: "${ANUBIS_ED25519_PRIVATE_KEY_HEX:?ANUBIS_ED25519_PRIVATE_KEY_HEX required}" BIND: ":8923" TARGET: "http://website-ukmesh:80" COOKIE_DOMAIN: "ukmesh.com" COOKIE_SECURE: "true" COOKIE_SAME_SITE: "Lax" COOKIE_EXPIRATION_TIME: "168h" POLICY_FNAME: "/data/botPolicy.yaml" SLOG_LEVEL: "INFO" CUSTOM_REAL_IP_HEADER: "CF-Connecting-IP" depends_on: website-ukmesh: condition: service_healthy networks: default: ipv4_address: 172.30.0.20 anubis-app-ukmesh: <<: [*anubis-base, *internal-hosts] mem_limit: "128m" memswap_limit: "128m" environment: ED25519_PRIVATE_KEY_HEX: "${ANUBIS_ED25519_PRIVATE_KEY_HEX:?ANUBIS_ED25519_PRIVATE_KEY_HEX required}" BIND: ":8923" TARGET: "http://app-ukmesh:80" COOKIE_DOMAIN: "ukmesh.com" COOKIE_SECURE: "true" COOKIE_SAME_SITE: "Lax" COOKIE_EXPIRATION_TIME: "168h" POLICY_FNAME: "/data/botPolicy.yaml" SLOG_LEVEL: "INFO" CUSTOM_REAL_IP_HEADER: "CF-Connecting-IP" depends_on: app-ukmesh: condition: service_healthy networks: default: ipv4_address: 172.30.0.21 anubis-health-check: <<: [*anubis-base, *internal-hosts] mem_limit: "128m" memswap_limit: "128m" environment: ED25519_PRIVATE_KEY_HEX: "${ANUBIS_ED25519_PRIVATE_KEY_HEX:?ANUBIS_ED25519_PRIVATE_KEY_HEX required}" BIND: ":8923" TARGET: "http://mesh-health-check:3090" COOKIE_DOMAIN: "ukmesh.com" COOKIE_SECURE: "true" COOKIE_SAME_SITE: "Lax" COOKIE_EXPIRATION_TIME: "168h" POLICY_FNAME: "/data/botPolicy.yaml" SLOG_LEVEL: "INFO" CUSTOM_REAL_IP_HEADER: "CF-Connecting-IP" depends_on: mesh-health-check: condition: service_healthy networks: default: ipv4_address: 172.30.0.22 # Cloudflare Tunnel currently routes healthcheck.ukmesh.com to # http://anubis-mesh-health-check:8923. Keep this alias unless the # tunnel ingress config is changed at Cloudflare too. aliases: - anubis-mesh-health-check cloudflared: image: cloudflare/cloudflared:latest restart: always logging: *json-log-limits mem_limit: "256m" memswap_limit: "256m" command: tunnel run profiles: [tunnel] networks: default: ipv4_address: 172.30.0.30 meshcore-beacon-ukmesh: {} environment: TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN} depends_on: backend: condition: service_healthy anubis-website-ukmesh: condition: service_started anubis-app-ukmesh: condition: service_started anubis-health-check: condition: service_started loki: <<: *internal-hosts image: grafana/loki:3.4 restart: always logging: *json-log-limits mem_limit: "512m" memswap_limit: "512m" command: -config.file=/etc/loki/loki.yaml volumes: - ./logging/loki.yaml:/etc/loki/loki.yaml:ro - loki_data:/loki promtail: <<: *internal-hosts image: grafana/promtail:3.4 restart: always logging: *json-log-limits mem_limit: "256m" memswap_limit: "256m" command: -config.file=/etc/promtail/promtail.yaml volumes: - ./logging/promtail.yaml:/etc/promtail/promtail.yaml:ro - /var/run/docker.sock:/var/run/docker.sock:ro depends_on: - loki grafana: <<: *internal-hosts image: grafana/grafana:11.5.0 restart: always logging: *json-log-limits mem_limit: "512m" memswap_limit: "512m" environment: GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD required} GF_USERS_ALLOW_SIGN_UP: "false" GF_AUTH_ANONYMOUS_ENABLED: "false" volumes: - ./logging/grafana/provisioning:/etc/grafana/provisioning:ro - ./logging/grafana/dashboards:/etc/grafana/dashboards:ro - grafana_data:/var/lib/grafana ports: - "127.0.0.1:3010:3000" depends_on: - loki networks: default: ipam: config: # Fixed proxy addresses make the backend's immediate-peer trust exact. - subnet: 172.30.0.0/24 meshcore-beacon-ukmesh: external: true name: meshcore-beacon-ukmesh_default volumes: timescaledb_data: mosquitto_data: mosquitto_log: redis_data: srtm_data: loki_data: grafana_data: mesh_health_check_data: