# --------------------------------------------------------------------------- # Biomarker stack: InfluxDB (storage) + Grafana (dashboards) + ntfy (alerts) # # Includes the Colmi parser and wearable-events as prebuilt images pulled # from Docker Hub - both built via one monorepo GitHub Actions workflow # at .github/workflows/docker-publish.yml (path-filtered, so pushing a # change to one service doesn't rebuild the other). The parser loops on # SYNC_INTERVAL_SECONDS, pulling the Gadgetbridge export from Nextcloud # WebDAV and writing points into InfluxDB - no external cron needed, and # no local build context required for this compose file, so it works # fine pasted directly into CasaOS. # # Set `image:` on the parser and wearable-events services below to your # own Docker Hub username/repo once you've pushed them. # # All secrets/config below have inline defaults but are meant to be # overridden via a .env file sitting next to this docker-compose.yml - # Compose reads .env automatically, no extra flag needed. See # .env.example in this same output for the variables to set there; # copy it to .env and fill in real values before first boot. Anything not # set in .env falls back to the (placeholder) default baked into this file. # # After first `docker compose up -d`: # 1. Set the INFLUXDB_TOKEN and INFLUXDB_INIT_ADMIN_TOKEN to the SAME # value in both the influxdb and parser service env blocks below # (or generate one via the InfluxDB UI and copy it into parser's env) # 2. Fill in the parser service's WEBDAV_* values for your Nextcloud # 3. Open Grafana at http://:3000 (default login admin/admin, # you'll be forced to change it) # 4. Add InfluxDB as a data source (URL: http://influxdb:8086, use the # token/org/bucket you set below) # 5. Open ntfy at http://:8090, subscribe to your chosen topic # from the ntfy Android/iOS app or web UI # --------------------------------------------------------------------------- x-casaos: architectures: - amd64 - arm64 main: grafana category: "Health" index: "/" port_map: "3000" scheme: "http" title: en_us: "Biomarker Stack" description: en_us: "Self-hosted InfluxDB + Grafana + ntfy stack for wearable biomarker data (HRV, HR, temperature, stress) synced from Gadgetbridge." tagline: en_us: "Own your biomarker data, self-hosted" icon: "https://grafana.com/static/img/menu/grafana2.svg" developer: "self" author: "self" services: influxdb: image: influxdb:2.7 container_name: biomarker-influxdb restart: unless-stopped # Runs as PUID:PGID instead of the image's default (usually root or a # fixed image-baked uid) so files written into the bind-mounted # APPDATA_ROOT paths below are owned by a uid/gid you control on the # host, not whatever the image happens to hard-code. Matters because # these are host bind mounts, not Docker-managed named volumes - # ownership is real host filesystem ownership. See env.stack.example. user: "${PUID:-1000}:${PGID:-1000}" ports: - "${INFLUXDB_HOST_PORT:-8086}:8086" environment: # First-run setup - only takes effect on an empty data volume. # After first boot, manage users/tokens via the InfluxDB UI instead # of editing these. DOCKER_INFLUXDB_INIT_MODE: setup DOCKER_INFLUXDB_INIT_USERNAME: ${INFLUXDB_INIT_USERNAME:-admin} DOCKER_INFLUXDB_INIT_PASSWORD: ${INFLUXDB_INIT_PASSWORD:-changeme_admin_password} DOCKER_INFLUXDB_INIT_ORG: ${INFLUXDB_ORG:-home} DOCKER_INFLUXDB_INIT_BUCKET: ${INFLUXDB_BUCKET:-health} DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: ${INFLUXDB_TOKEN:-changeme_admin_token} volumes: - ${APPDATA_ROOT:-/DATA/AppData/biomarker-stack}/influxdb/data:/var/lib/influxdb2 - ${APPDATA_ROOT:-/DATA/AppData/biomarker-stack}/influxdb/config:/etc/influxdb2 networks: - biomarker-net x-casaos: envs: - container: DOCKER_INFLUXDB_INIT_PASSWORD description: en_us: "InfluxDB admin password - change before first boot" - container: DOCKER_INFLUXDB_INIT_ADMIN_TOKEN description: en_us: "InfluxDB API token - used by Grafana and the parser script" ports: - container: "8086" description: en_us: "InfluxDB HTTP API / UI" volumes: - container: /var/lib/influxdb2 description: en_us: "InfluxDB time-series data" - container: /etc/influxdb2 description: en_us: "InfluxDB config" # Renders the Grafana provisioning templates (datasource + alerting + # dashboards) with envsubst before Grafana starts, then exits. Exists because # Grafana's alert-rule/contact-point provisioning does NOT support # $ENV_VAR interpolation the way datasource provisioning does - a # literal "$INFLUXDB_BUCKET" string was being sent straight to # InfluxDB and failing with "could not find bucket". Templating it # ourselves keeps every value centralized in .env instead of hardcoding # secrets (like the ntfy password) into repo-tracked YAML. grafana-provisioning-init: image: alpine:3.20 container_name: biomarker-grafana-provisioning-init restart: "no" environment: INFLUX_URL: "http://influxdb:8086" INFLUXDB_ORG: ${INFLUXDB_ORG:-home} INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-health} INFLUXDB_TOKEN: ${INFLUXDB_TOKEN:-changeme_admin_token} INFLUXDB_MEASUREMENT: ${INFLUXDB_MEASUREMENT:-colmi} ALERT_HRV_USER: ${ALERT_HRV_USER:-primary} # Which parser's HRV data the alert rule evaluates - see # grafana/provisioning-templates/alerting/rules.yaml.template. # Defaults to "colmi" since HRV isn't extracted by parser-amazfit # yet (see parser/amazfit/README.md). ALERT_HRV_SOURCE: ${ALERT_HRV_SOURCE:-colmi} NTFY_TOPIC: ${NTFY_TOPIC:-biomarker-alerts} NTFY_USER: ${NTFY_GRAFANA_USER:-grafana} NTFY_PASSWORD: ${NTFY_GRAFANA_PASSWORD:-changeme_ntfy_password} NTFY_PRIORITY: ${NTFY_PRIORITY:-4} volumes: - ./grafana/provisioning-templates:/templates:ro - ./grafana/render-provisioning.sh:/render-provisioning.sh:ro - grafana-provisioning:/output command: sh /render-provisioning.sh networks: - biomarker-net grafana: image: grafana/grafana:latest container_name: biomarker-grafana restart: unless-stopped # See the same note on the influxdb service above - matches ownership # of the bind-mounted grafana/data path to a host-controlled uid/gid. user: "${PUID:-1000}:${PGID:-1000}" ports: - "${GRAFANA_HOST_PORT:-3000}:3000" environment: GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme_grafana_password} GF_INSTALL_PLUGINS: "" # Alerting is enabled by default in modern Grafana - no extra flag # needed for the provisioned datasource/contact point/rule to # take effect, as long as grafana-provisioning-init has already # rendered them into the grafana-provisioning volume (see # depends_on below). volumes: - ${APPDATA_ROOT:-/DATA/AppData/biomarker-stack}/grafana/data:/var/lib/grafana - grafana-provisioning:/etc/grafana/provisioning:ro networks: - biomarker-net depends_on: influxdb: condition: service_started ntfy: condition: service_started grafana-provisioning-init: condition: service_completed_successfully x-casaos: envs: - container: GF_SECURITY_ADMIN_PASSWORD description: en_us: "Grafana admin password - change before first boot" ports: - container: "3000" description: en_us: "Grafana web UI" volumes: - container: /var/lib/grafana description: en_us: "Grafana dashboards, users, settings" - container: /etc/grafana/provisioning description: en_us: "Grafana datasource/alerting provisioning files (repo-tracked, read-only)" ntfy: image: binwiederhier/ntfy:latest container_name: biomarker-ntfy restart: unless-stopped # See the same note on the influxdb service above - matches ownership # of the bind-mounted ntfy/cache and ntfy/lib paths to a # host-controlled uid/gid. Because of this, NTFY_LISTEN_HTTP below is # set to an unprivileged port (8080, not the image's default 80) - # a non-root user can't bind ports below 1024 on most kernels, so # staying on 80 here would fail to start once PUID/PGID isn't root. user: "${PUID:-1000}:${PGID:-1000}" command: - serve environment: TZ: ${TZ:-Europe/London} NTFY_BASE_URL: "http://localhost:8090" # Keep this reachable only over Tailscale rather than opening it to # the wider internet - ntfy has no auth by default on the topic you # publish/subscribe to NTFY_LISTEN_HTTP: ":8080" NTFY_CACHE_FILE: "/var/cache/ntfy/cache.db" NTFY_AUTH_FILE: "/var/lib/ntfy/auth.db" NTFY_AUTH_DEFAULT_ACCESS: "deny-all" NTFY_ENABLE_SIGNUP: "false" ports: - "${NTFY_HOST_PORT:-8090}:8080" volumes: - ${APPDATA_ROOT:-/DATA/AppData/biomarker-stack}/ntfy/cache:/var/cache/ntfy - ${APPDATA_ROOT:-/DATA/AppData/biomarker-stack}/ntfy/lib:/var/lib/ntfy networks: - biomarker-net x-casaos: envs: - container: TZ description: en_us: "Timezone for notification timestamps" ports: - container: "8080" description: en_us: "ntfy web UI / publish-subscribe API" volumes: - container: /var/cache/ntfy description: en_us: "ntfy message cache" - container: /var/lib/ntfy description: en_us: "ntfy auth database (since NTFY_AUTH_DEFAULT_ACCESS is deny-all)" # Colmi ring parser. Renamed from the old `parser` service name as # part of the multi-device restructure (now a sibling of # parser-amazfit below, not "the" parser) - if you're upgrading an # existing deployment, `docker compose up` will recreate this # container under the new service name; it's stateless (no mounted # volume tied to the old name) so this is safe, just expect a brief # restart. parser-colmi: image: ${PARSER_COLMI_IMAGE:-dibz15/colmi2influx:latest} container_name: biomarker-parser-colmi restart: unless-stopped # Two profiles, not one: "colmi" alone (this device only) and "all" # (both devices) - a service can belong to more than one profile, # and starts if ANY of its own profiles are in the active set. See # COMPOSE_PROFILES in env.stack.example for how to pick between # them; docs/SETUP.md's own "Running just one device" has the # full explanation. profiles: - colmi - all environment: # --- WebDAV (Nextcloud) source --- WEBDAV_URL: ${WEBDAV_URL:-https://your-nextcloud-domain/remote.php/dav/} WEBDAV_PATH: ${WEBDAV_PATH:-files/your_nextcloud_user/GadgetBridge/} WEBDAV_USER: ${WEBDAV_USER:-your_nextcloud_user} WEBDAV_PASS: ${WEBDAV_PASS:-your_nextcloud_app_password} EXPORT_FILENAME: ${EXPORT_FILENAME:-Gadgetbridge} # --- Query window --- QUERY_DURATION: ${QUERY_DURATION:-86400} MAX_CATCHUP_SECONDS: ${MAX_CATCHUP_SECONDS:-2592000} CHECKPOINT_OVERLAP_SECONDS: ${CHECKPOINT_OVERLAP_SECONDS:-300} MAX_FUTURE_TOLERANCE_SECONDS: ${MAX_FUTURE_TOLERANCE_SECONDS:-300} # --- InfluxDB target - same network, so use the service name. # Reuses the same INFLUXDB_ORG/BUCKET/TOKEN vars as the influxdb # service above, so they only need setting once in .env. # # INFLUXDB_MEASUREMENT intentionally still defaults to "colmi" # (not e.g. "gadgetbridge") for backward compatibility - existing # deployments already have historical data under that measurement # name, and silently changing the default would break their # dashboards. parser-amazfit below shares this SAME variable # (both parsers should write to one measurement, disambiguated by # the `device`/`source` tags each point carries - not by # measurement name) - so if you rename this, update both. --- INFLUXDB_URL: "http://influxdb:8086" INFLUXDB_TOKEN: ${INFLUXDB_TOKEN:-changeme_admin_token} INFLUXDB_ORG: ${INFLUXDB_ORG:-home} INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-health} INFLUXDB_MEASUREMENT: ${INFLUXDB_MEASUREMENT:-colmi} # --- Behaviour --- SLEEP_HOURS: ${SLEEP_HOURS:-0,1,2,3,4,5,6} COLMI_TIMESTAMPS_ARE_MS: ${COLMI_TIMESTAMPS_ARE_MS:-Y} REMOVE_TEMP_DB: ${REMOVE_TEMP_DB:-Y} GADGETBRIDGE_USER: ${GADGETBRIDGE_USER:-primary} # Distinguishes this parser's checkpoint history from # parser-amazfit's, even though both share INFLUXDB_MEASUREMENT # and GADGETBRIDGE_USER above - see parser/common/checkpoint.py. PARSER_SOURCE: colmi # How often the loop re-runs (seconds). 1800 = every 30 min, plenty # given Gadgetbridge itself only auto-exports periodically. SYNC_INTERVAL_SECONDS: ${SYNC_INTERVAL_SECONDS:-1800} networks: - biomarker-net depends_on: - influxdb x-casaos: envs: - container: WEBDAV_URL description: en_us: "Nextcloud WebDAV base URL" - container: WEBDAV_PATH description: en_us: "Path to the Gadgetbridge export folder on Nextcloud" - container: WEBDAV_USER description: en_us: "Nextcloud username" - container: WEBDAV_PASS description: en_us: "Nextcloud app password (create a dedicated app password, not your main login)" - container: INFLUXDB_TOKEN description: en_us: "Must match the InfluxDB admin token set on the influxdb service" - container: SYNC_INTERVAL_SECONDS description: en_us: "How often to re-check the Nextcloud export, in seconds" # Amazfit Active 3 Premium parser. Best-effort implementation - the # HUAMI_* table/column names are guessed from Gadgetbridge's public # issue tracker, NOT verified against real Active 3 Premium hardware # yet (see parser/amazfit/README.md). Safe to run continuously # before the watch is even paired: every query gracefully skips a # missing/mismatched table instead of crashing, so before pairing # this just quietly does nothing every cycle. Once real data starts # flowing in, treat it as a starting point to verify, not as # confirmed-correct - watch the logs and follow the checklist in # that README. parser-amazfit: image: ${PARSER_AMAZFIT_IMAGE:-dibz15/amazfit2influx:latest} container_name: biomarker-parser-amazfit restart: unless-stopped # See parser-colmi's own comment on profiles above - same pattern, # this device's own profile plus "all". profiles: - amazfit - all environment: WEBDAV_URL: ${WEBDAV_URL:-https://your-nextcloud-domain/remote.php/dav/} WEBDAV_PATH: ${WEBDAV_PATH:-files/your_nextcloud_user/GadgetBridge/} WEBDAV_USER: ${WEBDAV_USER:-your_nextcloud_user} WEBDAV_PASS: ${WEBDAV_PASS:-your_nextcloud_app_password} EXPORT_FILENAME: ${EXPORT_FILENAME:-Gadgetbridge} # Optional - enables per-sample workout detail (GPS/HR/cadence) # extraction from Gadgetbridge's own "Auto export GPX/FIT # tracks" automations. No default - leave unset (empty) in your # own .env if you haven't set those automations up. NOT # automatically relative to WEBDAV_PATH above, despite the # similar name - see env.stack.example's own note on this # variable for the full explanation and a worked example. EXPORT_TRACKS_PATH: ${EXPORT_TRACKS_PATH:-} QUERY_DURATION: ${QUERY_DURATION:-86400} MAX_CATCHUP_SECONDS: ${MAX_CATCHUP_SECONDS:-2592000} CHECKPOINT_OVERLAP_SECONDS: ${CHECKPOINT_OVERLAP_SECONDS:-300} MAX_FUTURE_TOLERANCE_SECONDS: ${MAX_FUTURE_TOLERANCE_SECONDS:-300} # Same bucket/measurement/user as parser-colmi, deliberately - # see the note on parser-colmi's INFLUXDB_MEASUREMENT above. INFLUXDB_URL: "http://influxdb:8086" INFLUXDB_TOKEN: ${INFLUXDB_TOKEN:-changeme_admin_token} INFLUXDB_ORG: ${INFLUXDB_ORG:-home} INFLUXDB_BUCKET: ${INFLUXDB_BUCKET:-health} INFLUXDB_MEASUREMENT: ${INFLUXDB_MEASUREMENT:-colmi} SLEEP_HOURS: ${SLEEP_HOURS:-0,1,2,3,4,5,6} HUAMI_TIMESTAMPS_ARE_MS: ${HUAMI_TIMESTAMPS_ARE_MS:-Y} REMOVE_TEMP_DB: ${REMOVE_TEMP_DB:-Y} GADGETBRIDGE_USER: ${GADGETBRIDGE_USER:-primary} PARSER_SOURCE: amazfit SYNC_INTERVAL_SECONDS: ${SYNC_INTERVAL_SECONDS:-1800} networks: - biomarker-net depends_on: - influxdb wearable-events: image: ${WEARABLE_EVENTS_IMAGE:-dibz15/wearable-events-ui:latest} container_name: biomarker-wearable-events restart: unless-stopped # See the same note on the influxdb service above - matches ownership # of the bind-mounted wearable-events path (holds config.db) to a # host-controlled uid/gid. user: "${PUID:-1000}:${PGID:-1000}" environment: SQLITE_PATH: /data/config.db TZ: ${TZ:-UTC} INFLUX_URL: "http://influxdb:8086" INFLUX_TOKEN: ${INFLUXDB_TOKEN:-changeme_admin_token} INFLUX_ORG: ${INFLUXDB_ORG:-home} INFLUX_BUCKET: ${INFLUXDB_BUCKET:-health} EVENTS_MEASUREMENT: ${EVENTS_MEASUREMENT:-events} SLEEP_MEASUREMENT: ${SLEEP_MEASUREMENT:-subjective_sleep} SENSOR_MEASUREMENT: ${INFLUXDB_MEASUREMENT:-colmi} SYNC_INTERVAL_MINUTES: ${CALENDAR_SYNC_INTERVAL_MINUTES:-15} MIN_SLEEP_SESSION_SECONDS: ${MIN_SLEEP_SESSION_SECONDS:-10800} PORT: "8080" # First-run bootstrap only - creates one login if the users table # is empty. After that, add further household members from the # Manage tab (no public signup page). Use the same username as # that person's GADGETBRIDGE_USER value on their ring parser # instance so their data correlates later. ADMIN_USERNAME: ${WEARABLE_EVENTS_ADMIN_USERNAME:-} ADMIN_PASSWORD: ${WEARABLE_EVENTS_ADMIN_PASSWORD:-} SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-N} SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-30} volumes: - ${APPDATA_ROOT:-/DATA/AppData/biomarker-stack}/wearable-events:/data ports: - "${WEARABLE_EVENTS_HOST_PORT:-8081}:8080" networks: - biomarker-net depends_on: - influxdb x-casaos: envs: - container: SYNC_INTERVAL_MINUTES description: en_us: "How often to poll ICS calendar feeds, in minutes" ports: - container: "8080" description: en_us: "Wearable Events web UI (tagging, sleep score, calendar management)" volumes: - container: /data description: en_us: "SQLite config db (calendars, keyword rules, tag definitions)" networks: biomarker-net: driver: bridge volumes: # Rendered Grafana provisioning output (from grafana/provisioning-templates/ # via grafana-provisioning-init) - a named Docker volume rather than a # bind mount, since it's generated content, not something to edit by # hand or track in the repo. grafana-provisioning: