InfluxDB + Grafana Setup Guide

Use Health Export Pro with InfluxDB for time-series storage and Grafana for visualization. This is a popular stack for self-hosted health data dashboards.

Architecture

iPhone (Health Export Pro)
    │
    │  POST /api/health-data (JSON)
    ▼
Receiver Server (Node.js / Python)
    │
    │  Write points
    ▼
InfluxDB (time-series DB)
    │
    │  Query
    ▼
Grafana (dashboards)

Health Export Pro sends JSON to a small receiver that transforms health samples into InfluxDB points. Grafana reads from InfluxDB for dashboards.

Prerequisites

1. Docker Compose Stack

Create a docker-compose.yml:

version: "3"
services:
  influxdb:
    image: influxdb:2
    ports:
      - "8086:8086"
    volumes:
      - influxdb-data:/var/lib/influxdb2
    environment:
      DOCKER_INFLUXDB_INIT_MODE: setup
      DOCKER_INFLUXDB_INIT_USERNAME: admin
      DOCKER_INFLUXDB_INIT_PASSWORD: changeme123
      DOCKER_INFLUXDB_INIT_ORG: health
      DOCKER_INFLUXDB_INIT_BUCKET: health-data
      DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: my-health-token

  grafana:
    image: grafana/grafana
    ports:
      - "3001:3000"
    volumes:
      - grafana-data:/var/lib/grafana
    depends_on:
      - influxdb

  receiver:
    build: ./receiver
    ports:
      - "3000:3000"
    environment:
      INFLUXDB_URL: http://influxdb:8086
      INFLUXDB_TOKEN: my-health-token
      INFLUXDB_ORG: health
      INFLUXDB_BUCKET: health-data
      API_KEY: your-secret-key
    depends_on:
      - influxdb

volumes:
  influxdb-data:
  grafana-data:

Important: Change my-health-token, changeme123, and your-secret-key to real secrets.

2. Receiver Server

Create receiver/Dockerfile:

FROM node:20-slim
WORKDIR /app
COPY package.json .
RUN npm install
COPY index.js .
CMD ["node", "index.js"]

Create receiver/package.json:

{
  "name": "health-export-receiver",
  "dependencies": {
    "@influxdata/influxdb-client": "^1.35.0",
    "express": "^4.18.0"
  }
}

Create receiver/index.js:

const express = require("express");
const { InfluxDB, Point } = require("@influxdata/influxdb-client");

const app = express();
app.use(express.json({ limit: "50mb" }));

const influx = new InfluxDB({
  url: process.env.INFLUXDB_URL,
  token: process.env.INFLUXDB_TOKEN,
});
const writeApi = influx.getWriteApi(
  process.env.INFLUXDB_ORG,
  process.env.INFLUXDB_BUCKET,
  "ms",
);

// Auth check
app.use("/api/health-data", (req, res, next) => {
  const key = req.headers.authorization?.replace("Bearer ", "");
  if (key !== process.env.API_KEY)
    return res.status(401).json({ error: "unauthorized" });
  next();
});

// Map HealthKit type identifiers to short measurement names
function measurementName(typeId) {
  return typeId
    .replace("HKQuantityTypeIdentifier", "")
    .replace("HKCategoryTypeIdentifier", "")
    .replace(/([A-Z])/g, "_$1")
    .toLowerCase()
    .replace(/^_/, "");
}

app.post("/api/health-data", async (req, res) => {
  const { data, syncSource } = req.body;
  let count = 0;

  for (const [typeId, samples] of Object.entries(data)) {
    if (!Array.isArray(samples)) continue;

    if (typeId === "workouts") {
      for (const w of samples) {
        const point = new Point("workout")
          .tag("activity_type", String(w.workoutActivityType))
          .tag("source", syncSource)
          .floatField("duration_seconds", w.durationSeconds)
          .floatField("energy_burned", w.totalEnergyBurned || 0)
          .floatField("distance", w.totalDistance || 0)
          .timestamp(new Date(w.startDate));
        writeApi.writePoint(point);
        count++;
      }
    } else if (typeId === "medicationDoseEvents") {
      for (const m of samples) {
        const point = new Point("medication_dose_event")
          .tag("name", m.medicationName || "unknown")
          .tag("source", syncSource)
          .intField("log_status", Number(m.logStatus || 0))
          .floatField("dose_quantity", Number(m.doseQuantity || 0))
          .timestamp(new Date(m.startDate));
        writeApi.writePoint(point);
        count++;
      }
    } else {
      const name = measurementName(typeId);
      for (const s of samples) {
        const point = new Point(name)
          .tag("source", syncSource)
          .tag("unit", s.unit || "")
          .floatField("value", s.value)
          .timestamp(new Date(s.startDate));

        if (s.sourceRevision?.source?.name) {
          point.tag("device", s.sourceRevision.source.name);
        }

        writeApi.writePoint(point);
        count++;
      }
    }
  }

  await writeApi.flush();
  console.log(`Wrote ${count} points (${syncSource})`);
  res.json({ count });
});

app.listen(3000, () => console.log("Receiver on :3000"));

This receiver stores workout rows, medication dose events, and generic sample arrays. If you also want to persist workoutRoutes or static medications definitions, add explicit handlers for those sections.

3. Start the Stack

docker compose up -d

Verify services are running:

docker compose ps
# All three services should show "Up"

4. Configure Health Export Pro

  1. Open Health Export Pro → SettingsREST API Destination
  2. Server URL: http://<your-server-ip>:3000 (or https://... with a reverse proxy)
  3. Endpoint Path: /api/health-data
  4. Auth Type: Bearer
  5. API Key: the value you set for API_KEY in docker-compose.yml
  6. Tap Test Connection

5. Set Up Grafana

  1. Open http://<your-server-ip>:3001 (default login: admin / admin)
  2. Go to ConnectionsData SourcesAdd data sourceInfluxDB
  3. Configure:
    • Query Language: Flux
    • URL: http://influxdb:8086
    • Organization: health
    • Token: my-health-token
    • Default Bucket: health-data
  4. Click Save & Test

Example Dashboard Panels

Resting Heart Rate (line chart):

from(bucket: "health-data")
  |> range(start: -30d)
  |> filter(fn: (r) => r._measurement == "heart_rate")
  |> filter(fn: (r) => r._field == "value")
  |> aggregateWindow(every: 1d, fn: min, createEmpty: false)

Daily Step Count (bar chart):

from(bucket: "health-data")
  |> range(start: -30d)
  |> filter(fn: (r) => r._measurement == "step_count")
  |> filter(fn: (r) => r._field == "value")
  |> aggregateWindow(every: 1d, fn: sum, createEmpty: false)

Sleep Duration (line chart):

from(bucket: "health-data")
  |> range(start: -30d)
  |> filter(fn: (r) => r._measurement == "sleep_analysis")
  |> filter(fn: (r) => r._field == "value")
  |> filter(fn: (r) => r.value >= 1)
  |> aggregateWindow(every: 1d, fn: count, createEmpty: false)

Weight Trend (line chart):

from(bucket: "health-data")
  |> range(start: -90d)
  |> filter(fn: (r) => r._measurement == "body_mass")
  |> filter(fn: (r) => r._field == "value")
  |> aggregateWindow(every: 1d, fn: last, createEmpty: false)

Workout Summary (table):

from(bucket: "health-data")
  |> range(start: -7d)
  |> filter(fn: (r) => r._measurement == "workout")
  |> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")

6. Optional: HTTPS with Caddy

For remote access, add a reverse proxy. Add to docker-compose.yml:

caddy:
  image: caddy:2
  ports:
    - "80:80"
    - "443:443"
  volumes:
    - ./Caddyfile:/etc/caddy/Caddyfile
    - caddy-data:/data

Create Caddyfile:

health.yourdomain.com {
    reverse_proxy receiver:3000
}

grafana.yourdomain.com {
    reverse_proxy grafana:3000
}

Add caddy-data to the volumes section. Then use https://health.yourdomain.com in the app.

Retention & Storage

InfluxDB 2.x supports retention policies. For long-term health data, set a generous retention or keep the default (infinite):

# Set 5-year retention (optional)
docker compose exec influxdb influx bucket update \
  --name health-data \
  --retention 43800h \
  --token my-health-token

Typical storage: ~1 KB per 100 health samples. A year of Apple Watch data (heart rate every 5 min + daily metrics) is roughly 50-100 MB.