name: ${COMPOSE_PROJECT_NAME:-meshcore-analytics} x-anubis-base: &anubis-base image: ghcr.io/techarohq/anubis@sha256:5cf4c0d6c7ea01a356c6685a779971bdd28d6a5a4a6719201f73a9c4ae108db0 restart: always user: "1000:1000" read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true tmpfs: - /tmp:rw,noexec,nosuid,size=16m,mode=1777 logging: &json-log-limits driver: journald options: tag: "{{.Name}}" labels: "com.docker.compose.service,com.docker.compose.project" volumes: - ./anubis/botPolicy.yaml:/data/botPolicy.yaml:ro x-internal-hosts: &internal-hosts {} # Docker's internal DNS handles service name resolution x-application-hardening: &application-hardening read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true tmpfs: - /tmp:rw,noexec,nosuid,size=64m,mode=1777 services: # 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, *application-hardening] image: ${BACKEND_IMAGE:-meshcore-analytics-backend:local} build: context: . dockerfile: Dockerfile.backend args: SOURCE_REVISION: ${SOURCE_REVISION:-local} 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 image: ${BACKEND_IMAGE:-meshcore-analytics-backend:local} build: context: . dockerfile: Dockerfile.backend args: SOURCE_REVISION: ${SOURCE_REVISION:-local} 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} DATABASE_APPLICATION_NAME: meshcore-backend OWNER_DATABASE_APPLICATION_NAME: meshcore-backend-owner-auth OWNER_DATABASE_ADMIN_APPLICATION_NAME: meshcore-backend-owner-auth-admin MQTT_BROKER_URL: ${MQTT_BROKER_URL:-ws://mosquitto:9001} MQTT_USERNAME: ${MQTT_USERNAME:-backend} MQTT_PASSWORD: ${MQTT_PASSWORD} MQTT_CLIENT_ID: ${MQTT_CLIENT_ID:-meshcore-analytics-ingest} REDIS_URL: ${REDIS_URL:-redis://redis:6379} REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD required} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} PORT: ${PORT:-3000} METRICS_PORT: ${METRICS_PORT:-9091} 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 OWNER_ACL_RELOAD_URL: http://mosquitto-reloader:8080/reload OWNER_ACL_RELOAD_TOKEN: ${JWT_SECRET:?JWT_SECRET required} 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} API_RATE_LIMIT_MAX: ${API_RATE_LIMIT_MAX:-120} RADIO_BOT_URL: ${RADIO_BOT_URL:-} PUBLIC_FEATURE_INFERRED_NODES_ENABLED: ${PUBLIC_FEATURE_INFERRED_NODES_ENABLED:-true} PUBLIC_FEATURE_PACKET_ARCS_ENABLED: ${PUBLIC_FEATURE_PACKET_ARCS_ENABLED:-true} PUBLIC_FEATURE_HEATMAP_ENABLED: ${PUBLIC_FEATURE_HEATMAP_ENABLED:-false} PUBLIC_FEATURE_CONFIG_TTL_SECONDS: ${PUBLIC_FEATURE_CONFIG_TTL_SECONDS:-30} # 15 min: the ukmesh chart snapshot regen runs multibyte analytics that # exceed 5 min on the 2vCPU VPS (was 300000; raised 2026-08-08) DATABASE_STATEMENT_TIMEOUT_MS: 900000 DATABASE_POOL_MAX: ${DATABASE_POOL_MAX:-16} STATS_DB_QUERY_CONCURRENCY: ${STATS_DB_QUERY_CONCURRENCY:-2} STATS_AGGREGATE_READS_ENABLED: ${STATS_AGGREGATE_READS_ENABLED:-false} STATS_AGGREGATE_SHADOW_ENABLED: ${STATS_AGGREGATE_SHADOW_ENABLED:-false} WARMUP_NETWORKS: ${WARMUP_NETWORKS:-ukmesh} DATABASE_SKIP_SCHEMA_INIT: "true" WS_INITIAL_STATE_ENABLED: "1" WS_INITIAL_STATE_DB_TIMEOUT_MS: ${WS_INITIAL_STATE_DB_TIMEOUT_MS:-30000} 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} # The 30s lifecycle deadline reserves 25s for MQTT disconnect and queue drain. MQTT_INGEST_QUEUE_MAX: ${MQTT_INGEST_QUEUE_MAX:-20000} 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} LINK_QUEUE_V3_DEAD_MAX_JOBS: ${LINK_QUEUE_V3_DEAD_MAX_JOBS:-1000} LINK_QUEUE_V3_DEAD_MAX_BYTES: ${LINK_QUEUE_V3_DEAD_MAX_BYTES:-33554432} LINK_QUEUE_V3_DEAD_RETENTION_MS: ${LINK_QUEUE_V3_DEAD_RETENTION_MS:-2592000000} 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:-} HEALTH_VOLUME_PATHS: database=/health-volumes/database=${HEALTH_DATABASE_BUDGET_BYTES:-322122547200},redis=/health-volumes/redis=${HEALTH_REDIS_BUDGET_BYTES:-4294967296},terrain=/health-volumes/terrain=${HEALTH_TERRAIN_BUDGET_BYTES:-53687091200},logs=/health-volumes/logs=${HEALTH_LOG_BUDGET_BYTES:-21474836480} DATA_LIFECYCLE_RETENTION_ENABLED: ${DATA_LIFECYCLE_RETENTION_ENABLED:-false} DATA_LIFECYCLE_COMPRESSION_ENABLED: ${DATA_LIFECYCLE_COMPRESSION_ENABLED:-false} DATA_LIFECYCLE_RETENTION_TARGETS: ${DATA_LIFECYCLE_RETENTION_TARGETS:-} DATA_LIFECYCLE_RESTORE_RECEIPT_PATH: /backup-receipts/latest.json DATA_LIFECYCLE_RESTORE_RECEIPT_SIGNATURE_PATH: /backup-receipts/latest.json.sig DATA_LIFECYCLE_RECEIPT_VERIFY_KEY_PATH: /backup-receipts/verify.pem DATA_LIFECYCLE_RECEIPT_MAX_AGE_DAYS: ${DATA_LIFECYCLE_RECEIPT_MAX_AGE_DAYS:-7} SYNTHETIC_SUCCESS_TTL_MS: ${SYNTHETIC_SUCCESS_TTL_MS:-960000} NODE_ENV: production depends_on: db-migrate: condition: service_completed_successfully ports: - "127.0.0.1:3000:3000" volumes: - mosquitto_log:/mosquitto/log:ro - ./mosquitto:/mosquitto/config - timescaledb_data:/health-volumes/database:ro - redis_data:/health-volumes/redis:ro - srtm_data:/health-volumes/terrain:ro - loki_data:/health-volumes/logs:ro - ${BACKUP_RECEIPT_HOST_DIR:-./backup-receipts}:/backup-receipts:ro group_add: - "1883" read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m cap_drop: - ALL security_opt: - no-new-privileges:true 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, *application-hardening] image: ${BACKEND_IMAGE:-meshcore-analytics-backend:local} build: context: . dockerfile: Dockerfile.backend args: SOURCE_REVISION: ${SOURCE_REVISION:-local} 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} DATABASE_APPLICATION_NAME: meshcore-path-learning 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} PATH_LEARNING_INTERVAL_MS: ${PATH_LEARNING_INTERVAL_MS:-3600000} NODE_ENV: production depends_on: db-migrate: condition: service_completed_successfully health-worker: <<: [*internal-hosts, *application-hardening] image: ${BACKEND_IMAGE:-meshcore-analytics-backend:local} build: context: . dockerfile: Dockerfile.backend args: SOURCE_REVISION: ${SOURCE_REVISION:-local} 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} DATABASE_APPLICATION_NAME: meshcore-health-worker 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" STALE_MQTT_OBSERVER_CLEANUP_DAYS: ${STALE_MQTT_OBSERVER_CLEANUP_DAYS:-30} STALE_MQTT_OBSERVER_CLEANUP_INTERVAL_MS: ${STALE_MQTT_OBSERVER_CLEANUP_INTERVAL_MS:-21600000} HEALTH_VOLUME_PATHS: database=/health-volumes/database=${HEALTH_DATABASE_BUDGET_BYTES:-322122547200},redis=/health-volumes/redis=${HEALTH_REDIS_BUDGET_BYTES:-4294967296},terrain=/health-volumes/terrain=${HEALTH_TERRAIN_BUDGET_BYTES:-53687091200},logs=/health-volumes/logs=${HEALTH_LOG_BUDGET_BYTES:-21474836480} DATA_LIFECYCLE_RETENTION_ENABLED: "true" DATA_LIFECYCLE_RETENTION_TARGETS: ${DATA_LIFECYCLE_RETENTION_TARGETS:-worker_health_snapshots,operational_check_results} NODE_ENV: production depends_on: db-migrate: condition: service_completed_successfully volumes: - timescaledb_data:/health-volumes/database:ro - redis_data:/health-volumes/redis:ro - srtm_data:/health-volumes/terrain:ro - loki_data:/health-volumes/logs:ro synthetic-monitor: <<: [*internal-hosts, *application-hardening] image: ${BACKEND_IMAGE:-meshcore-analytics-backend:local} build: context: . dockerfile: Dockerfile.backend args: SOURCE_REVISION: ${SOURCE_REVISION:-local} 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_APPLICATION_NAME: meshcore-synthetic-monitor 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_FULL_INTERVAL_MS: ${SYNTHETIC_FULL_INTERVAL_MS:-720000} SYNTHETIC_TIMEOUT_MS: ${SYNTHETIC_TIMEOUT_MS:-10000} SYNTHETIC_INITIAL_STATE_TIMEOUT_MS: ${SYNTHETIC_INITIAL_STATE_TIMEOUT_MS:-35000} SYNTHETIC_FAILURE_THRESHOLD: ${SYNTHETIC_FAILURE_THRESHOLD:-3} ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-http://alert-receiver:8080/alerts} NODE_ENV: production depends_on: db-migrate: condition: service_completed_successfully backend: condition: service_healthy alert-receiver: condition: service_healthy link-backfill-worker: <<: [*internal-hosts, *application-hardening] image: ${BACKEND_IMAGE:-meshcore-analytics-backend:local} build: context: . dockerfile: Dockerfile.backend args: SOURCE_REVISION: ${SOURCE_REVISION:-local} restart: on-failure:3 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: db-migrate: condition: service_completed_successfully backend: condition: service_healthy link-worker: condition: service_healthy link-worker: <<: [*internal-hosts, *application-hardening] image: ${RF_WORKER_IMAGE:-meshcore-analytics-rf-worker:local} build: context: ./viewshed-worker dockerfile: Dockerfile args: SOURCE_REVISION: ${SOURCE_REVISION:-local} 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} DATABASE_APPLICATION_NAME: meshcore-link-worker 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} LINK_QUEUE_V3_DEAD_MAX_JOBS: ${LINK_QUEUE_V3_DEAD_MAX_JOBS:-1000} LINK_QUEUE_V3_DEAD_MAX_BYTES: ${LINK_QUEUE_V3_DEAD_MAX_BYTES:-33554432} LINK_QUEUE_V3_DEAD_RETENTION_MS: ${LINK_QUEUE_V3_DEAD_RETENTION_MS:-2592000000} SRTM_MAX_LINK_TILES: ${SRTM_MAX_LINK_TILES:-64} SRTM_CACHE_MAX_BYTES: ${SRTM_CACHE_MAX_BYTES:-21474836480} 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: db-migrate: condition: service_completed_successfully # Canonical whole-region RF calculator. HopReach owns propagation and # raster output; UK Mesh supplies only the internal data adapter, versioned # boundary, orchestration profile, and native map consumer. hopreach: <<: *internal-hosts image: ${HOPREACH_IMAGE:-meshcore-analytics-hopreach:local} build: context: ./third_party/hopreach dockerfile: Dockerfile args: VERSION: v0.1.32-ukmesh.3 SOURCE_REVISION: ${SOURCE_REVISION:-local} restart: always logging: *json-log-limits cpus: ${HOPREACH_CPUS:-4.0} mem_limit: ${HOPREACH_MEMORY_LIMIT:-8g} memswap_limit: ${HOPREACH_MEMORY_LIMIT:-8g} environment: HOPREACH_CONFIG: /config/config.yaml GOMAXPROCS: ${HOPREACH_CPU_WORKERS:-4} volumes: - rf_coverage_data:/data - ./rf-coverage/config.ukmesh.yaml:/config/config.yaml:ro - ./rf-coverage/boundaries/uk-operational-v1.geojson:/config/uk-operational-v1.geojson:ro depends_on: backend: condition: service_healthy app-ukmesh: <<: [*internal-hosts, *application-hardening] image: ${APP_IMAGE:-meshcore-analytics-app:local} build: context: . dockerfile: Dockerfile.app args: SOURCE_REVISION: ${SOURCE_REVISION:-local} VITE_APP_HOSTNAME: app.ukmesh.com VITE_BUILD_TARGET: app VITE_NETWORK: ukmesh VITE_SITE: ukmesh VITE_RF_COVERAGE_ENABLED: ${RF_COVERAGE_ENABLED:-true} restart: always logging: *json-log-limits mem_limit: "128m" memswap_limit: "128m" depends_on: backend: condition: service_healthy ports: - "127.0.0.1:3003:8080" networks: default: ipv4_address: 172.30.0.10 volumes: - ${TERRAIN_TILES_PATH:-./terrain-tiles}:/terrain-tiles:ro - rf_coverage_data:/rf-coverage-data:ro tmpfs: - /tmp:rw,noexec,nosuid,size=16m,mode=1777 - /var/cache/nginx:rw,noexec,nosuid,size=32m,uid=101,gid=101,mode=0755 - /var/run:rw,noexec,nosuid,size=4m,uid=101,gid=101,mode=0755 healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null && wget -qO- http://127.0.0.1:8080/readyz >/dev/null"] interval: 30s timeout: 10s retries: 6 start_period: 15s website-ukmesh: <<: [*internal-hosts, *application-hardening] image: ${WEBSITE_IMAGE:-meshcore-analytics-website:local} build: context: . dockerfile: Dockerfile.website args: SOURCE_REVISION: ${SOURCE_REVISION:-local} VITE_APP_HOSTNAME: app.ukmesh.com VITE_BUILD_TARGET: public-website VITE_SITE: ukmesh restart: always logging: *json-log-limits mem_limit: "128m" memswap_limit: "128m" ports: - "127.0.0.1:3004:8080" networks: default: ipv4_address: 172.30.0.11 depends_on: backend: condition: service_healthy volumes: - ${TERRAIN_TILES_PATH:-./terrain-tiles}:/terrain-tiles:ro tmpfs: - /tmp:rw,noexec,nosuid,size=16m,mode=1777 - /var/cache/nginx:rw,noexec,nosuid,size=32m,uid=101,gid=101,mode=0755 - /var/run:rw,noexec,nosuid,size=4m,uid=101,gid=101,mode=0755 healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null && wget -qO- http://127.0.0.1:8080/readyz >/dev/null"] interval: 30s timeout: 10s retries: 6 start_period: 15s mesh-health-check-data-init: <<: *application-hardening image: ${HEALTHCHECK_IMAGE:-meshcore-analytics-health-check:local} user: "0:0" entrypoint: ["/bin/sh", "-ec"] command: - | touch /app/data/.meshcore-initialized chown -R node:node /app/data find /app/data -xdev -type f -exec chmod 0640 {} + chmod 0750 /app/data restart: "no" network_mode: "none" mem_limit: "64m" memswap_limit: "64m" cap_add: - CHOWN - DAC_OVERRIDE - FOWNER volumes: - mesh_health_check_data:/app/data mesh-health-check: <<: [*internal-hosts, *application-hardening] image: ${HEALTHCHECK_IMAGE:-meshcore-analytics-health-check:local} build: context: . dockerfile: Dockerfile.mesh-health-check args: SOURCE_REVISION: ${SOURCE_REVISION:-local} MESH_HEALTH_CHECK_REF: ${HEALTHCHECK_SOURCE_REF:?HEALTHCHECK_SOURCE_REF full commit required} 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: mesh-health-check-data-init: condition: service_completed_successfully 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, *application-hardening] image: ${WEBSITE_DEV_IMAGE:-meshcore-analytics-website-dev:local} build: context: . dockerfile: Dockerfile.website args: SOURCE_REVISION: ${SOURCE_REVISION:-local} VITE_APP_HOSTNAME: ${VITE_DEV_APP_HOSTNAME:-test.ukmesh.com} VITE_BUILD_TARGET: dev-website 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} restart: always profiles: [dev] logging: *json-log-limits mem_limit: "128m" memswap_limit: "128m" ports: - "127.0.0.1:3006:8080" networks: default: ipv4_address: 172.30.0.12 depends_on: backend: condition: service_healthy volumes: - ${TERRAIN_TILES_PATH:-./terrain-tiles}:/terrain-tiles:ro tmpfs: - /tmp:rw,noexec,nosuid,size=16m,mode=1777 - /var/cache/nginx:rw,noexec,nosuid,size=32m,uid=101,gid=101,mode=0755 - /var/run:rw,noexec,nosuid,size=4m,uid=101,gid=101,mode=0755 healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/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:8080" 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:8080" 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@sha256:e39ee8da81ad5e05d77f38d2f51c60ca51bf2a8450ac3abab50c17fdb91d91bf restart: always logging: *json-log-limits mem_limit: "256m" memswap_limit: "256m" user: "65532:65532" read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true tmpfs: - /tmp:rw,noexec,nosuid,size=16m,mode=1777 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@sha256:5fe9fa99e9a747297cdf0239a5b25d192d8f668bd6505b09beef4dffcab5aac2 restart: always logging: *json-log-limits mem_limit: "512m" memswap_limit: "512m" command: -config.file=/etc/loki/loki.yaml read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true tmpfs: - /tmp:rw,noexec,nosuid,size=32m volumes: - ./logging/loki.yaml:/etc/loki/loki.yaml:ro - loki_data:/loki healthcheck: test: ["CMD", "/busybox/wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3100/ready"] interval: 30s timeout: 10s retries: 5 start_period: 20s alloy: <<: *internal-hosts image: grafana/alloy@sha256:491b0578c04983fd54fe99b587b6fab4404dc46d0dc16677bd6b00cc1140b308 restart: always logging: driver: json-file options: max-size: "10m" max-file: "3" mem_limit: "256m" memswap_limit: "256m" user: "473:473" group_add: - "${JOURNAL_GID:-999}" read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true command: - run - --server.http.listen-addr=0.0.0.0:12345 - --storage.path=/var/lib/alloy/data - /etc/alloy/config.alloy volumes: - ./logging/alloy/config.alloy:/etc/alloy/config.alloy:ro - /var/log/journal:/var/log/journal:ro - /run/log/journal:/run/log/journal:ro - /etc/machine-id:/etc/machine-id:ro - alloy_data:/var/lib/alloy healthcheck: test: ["CMD", "/bin/alloy", "validate", "/etc/alloy/config.alloy"] interval: 30s timeout: 10s retries: 5 start_period: 10s depends_on: loki: condition: service_healthy prometheus: <<: *internal-hosts image: prom/prometheus@sha256:63805ebb8d2b3920190daf1cb14a60871b16fd38bed42b857a3182bc621f4996 restart: always logging: *json-log-limits mem_limit: "256m" memswap_limit: "256m" command: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.retention.time=15d - --storage.tsdb.retention.size=8GB read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true tmpfs: - /tmp:rw,noexec,nosuid,size=32m volumes: - ./logging/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./logging/rules:/etc/prometheus/rules:ro - prometheus_data:/prometheus ports: - "127.0.0.1:9090:9090" healthcheck: test: ["CMD", "/bin/wget", "-q", "-O", "/dev/null", "http://127.0.0.1:9090/-/ready"] interval: 30s timeout: 10s retries: 5 start_period: 20s depends_on: backend: condition: service_healthy alertmanager: condition: service_healthy postgres-exporter: condition: service_healthy redis-exporter: condition: service_healthy node-exporter: condition: service_healthy blackbox-exporter: condition: service_healthy grafana: <<: *internal-hosts image: grafana/grafana@sha256:0a2874cf39c6487093c682215f7c7903ed8646d78ae5f911af945d2dfcc0a447 restart: always logging: *json-log-limits mem_limit: "512m" memswap_limit: "512m" read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true tmpfs: - /tmp:rw,noexec,nosuid,size=64m 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" healthcheck: test: ["CMD", "/usr/bin/wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/api/health"] interval: 30s timeout: 10s retries: 5 start_period: 30s depends_on: loki: condition: service_healthy prometheus: condition: service_healthy alert-receiver: <<: *internal-hosts image: ${BACKEND_IMAGE:-meshcore-analytics-backend:local} build: context: . dockerfile: Dockerfile.backend args: SOURCE_REVISION: ${SOURCE_REVISION:-local} command: ["node", "dist/workers/alert-receiver.js"] restart: always logging: *json-log-limits mem_limit: "128m" memswap_limit: "128m" read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true tmpfs: - /tmp:rw,noexec,nosuid,size=16m environment: DATABASE_APPLICATION_NAME: meshcore-alert-receiver ALERT_FORWARD_URL: ${ALERT_FORWARD_URL:-} ALERT_RECEIVER_PATH: /var/lib/meshcore-alerts/alerts.jsonl NODE_ENV: production volumes: - alert_receiver_data:/var/lib/meshcore-alerts healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null"] interval: 30s timeout: 5s retries: 5 start_period: 10s alertmanager: <<: *internal-hosts image: prom/alertmanager@sha256:27c475db5fb156cab31d5c18a4251ac7ed567746a2483ff264516437a39b15ba restart: always logging: *json-log-limits mem_limit: "128m" memswap_limit: "128m" command: - --config.file=/etc/alertmanager/alertmanager.yml - --storage.path=/alertmanager - --data.retention=120h read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true tmpfs: - /tmp:rw,noexec,nosuid,size=16m volumes: - ./logging/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro - alertmanager_data:/alertmanager healthcheck: test: ["CMD", "/bin/wget", "-q", "-O", "/dev/null", "http://127.0.0.1:9093/-/ready"] interval: 30s timeout: 5s retries: 5 start_period: 10s depends_on: alert-receiver: condition: service_healthy postgres-exporter: <<: *internal-hosts image: quay.io/prometheuscommunity/postgres-exporter@sha256:fb96c4413985d4b23ab02b19022b3d70a86c8e0a62f41ab15ebb6f4673781a5d restart: always logging: *json-log-limits mem_limit: "128m" memswap_limit: "128m" read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true environment: DATA_SOURCE_NAME: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}?sslmode=disable healthcheck: test: ["CMD", "/bin/postgres_exporter", "--version"] interval: 30s timeout: 5s retries: 5 redis-exporter: <<: *internal-hosts image: oliver006/redis_exporter@sha256:4c8000eb3525e0f6ed1327499861e272fcce358a9e7a87dd9db74c44607e6d8e restart: always logging: *json-log-limits mem_limit: "128m" memswap_limit: "128m" read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true environment: REDIS_ADDR: redis://redis:6379 REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD required} REDIS_EXPORTER_CHECK_SINGLE_KEYS: meshcore:link:v3:worker_heartbeat healthcheck: test: ["CMD", "/redis_exporter", "-version"] interval: 30s timeout: 5s retries: 5 node-exporter: <<: *internal-hosts image: prom/node-exporter@sha256:d00a542e409ee618a4edc67da14dd48c5da66726bbd5537ab2af9c1dfc442c8a restart: always logging: *json-log-limits mem_limit: "128m" memswap_limit: "128m" pid: host command: - --path.rootfs=/host - --path.procfs=/host/proc - --path.sysfs=/host/sys - --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|var/lib/docker/.+)($|/) read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true volumes: - /:/host:ro,rslave healthcheck: test: ["CMD", "/bin/node_exporter", "--version"] interval: 30s timeout: 5s retries: 5 blackbox-exporter: <<: *internal-hosts image: prom/blackbox-exporter@sha256:a50c4c0eda297baa1678cd4dc4712a67fdea713b832d43ce7fcc5f9bea05094d restart: always logging: *json-log-limits mem_limit: "128m" memswap_limit: "128m" command: ["--config.file=/etc/blackbox_exporter/config.yml"] read_only: true cap_drop: - ALL security_opt: - no-new-privileges:true volumes: - ./logging/blackbox.yml:/etc/blackbox_exporter/config.yml:ro healthcheck: test: ["CMD", "/bin/blackbox_exporter", "--version"] interval: 30s timeout: 5s retries: 5 networks: default: ipam: config: # Fixed proxy addresses make the backend's immediate-peer trust exact. # Keep Docker's dynamic allocator away from those reserved low # addresses when the full stack starts services concurrently. - subnet: 172.30.0.0/24 ip_range: 172.30.0.128/25 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: prometheus_data: alloy_data: alert_receiver_data: alertmanager_data: mesh_health_check_data: rf_coverage_data: