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
- Docker and Docker Compose (simplest setup)
- A machine reachable from your iPhone (VPS, home server, or same Wi-Fi network)
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
- Open Health Export Pro → Settings → REST API Destination
- Server URL:
http://<your-server-ip>:3000(orhttps://...with a reverse proxy) - Endpoint Path:
/api/health-data - Auth Type: Bearer
- API Key: the value you set for
API_KEYin docker-compose.yml - Tap Test Connection
5. Set Up Grafana
- Open
http://<your-server-ip>:3001(default login:admin/admin) - Go to Connections → Data Sources → Add data source → InfluxDB
- Configure:
- Query Language: Flux
- URL:
http://influxdb:8086 - Organization:
health - Token:
my-health-token - Default Bucket:
health-data
- 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.