Skip to main content
Glama

health-db

Self-hosted Apple Health data platform: the Health Auto Export (HAE) iOS app POSTs JSON v2 exports to a FastAPI ingest service, which archives every payload verbatim (archive-first), normalizes it, and stores it in PostgreSQL. Queries are served by an MCP streamable-HTTP server (12 tools + 3 resources, Bearer auth) with a thin CLI wrapper for dev testing. Runs as a single Docker container next to any PostgreSQL 18 instance.

iPhone (HAE automations) ──POST /api/ingest──►  ingest :8000 ──► data/archive/ (verbatim, iron law)
                                                     │                     │
                                                     ▼                     ▼
                                              PostgreSQL 18 ◄── health-replay (backfill / outage queue)
                                                     │
MCP clients (Claude, …) ──streamable-http :8100/mcp──► queries.py (shared read layer) ◄── health CLI

Surfaces

Surface

Endpoint

Auth

Ingest

POST /api/ingest

X-Ingest-Token header (INGEST_SECRET)

Liveness

GET /api/health

none (includes DB connectivity)

Ingest stats

GET /api/ingest/stats

INGEST_SECRET

MCP

http://<host>:8100/mcp (streamable-http)

Authorization: Bearer <INGEST_SECRET>

CLI

uv run health <command>

uses PSQL_* env directly

MCP tools

query_day(date, metric?) · get_metric_series(metric, from, to) · list_metrics() · list_workouts(from?, to?, limit, offset) — paged (default 500, max 2000; next_offset None = last page) · get_workout_detail(id, route_offset?, route_limit?) — HR inline; GPS route paged (default 1000, max 10000; route_count + next_route_offset None = last page) · get_trends(metric, window) · freshness_check() · raw_samples(metric?, from?, to?, limit, offset) · list_ecgs(from?, to?, limit) — paged (default 50, max 500; classification, avg HR, voltage count included) · get_ecg_detail(id, voltage_offset?, voltage_limit?) — voltage samples paged (default 1000, max 10000; next_voltage_offset None = last page) · list_heart_notifications(from?, to?, limit) — paged (default 50, max 500; nested HR/HRV samples included) · list_sleep_sessions(from?, to?, limit, offset) — one row per recognized aggregated-sleep record (per-source rows never merged; durations in hours verbatim)

MCP resources

health://metrics (catalog) · health://freshness · health://schema

CLI

uv run health query-day 2026-09-10
uv run health metrics
uv run health workouts --from 2026-09-01 --to 2026-09-10 --limit 100 --offset 0
uv run health trends step_count --window 30
uv run health freshness
uv run health raw --metric step_count --from 2026-09-01 --to 2026-09-10 --limit 1000
uv run health routes <workout-id> --offset 1000 --limit 1000
uv run health sleep --from 2026-09-27 --to 2026-09-28 --limit 100 --offset 0

JSON to stdout, diagnostics to stderr. Thin wrapper over the same query layer the MCP tools use.

Related MCP server: Apple Health AI Bridge MCP Server

Data semantics (the invariants)

  • Archive-first: every authorized payload is on disk (data/archive/YYYY/MM/DD/) before any processing verdict. DB outages queue payloads in data/pending/ (503 + Retry-After: 300) and health-replay --pending drains them when the DB returns. Nothing is ever lost.

  • Idempotency: duplicate payloads (same sha256) return 200 {"outcome":"duplicate"}; samples upsert latest-wins on (metric_id, source_id, ts); workouts upsert by HAE UUID. Re-posting any chunk is always safe — row counts never change.

  • Canonical units: non-canonical-unit samples are converted with an exact factor when one exists, else skipped visibly (counted in the ingest response's skipped.unit_unconvertible). Conversion factors (iron law 4, amended 2026-09-17-normalizer-audit): lb→kg ÷ 2.20462, st→kg ÷ 0.157473, °F→°C (°F−32)×5/9 (wrist temperature), kJ→kcal ÷ 4.184. Converted rows store the canonical unit string; units with no exact factor are never stored next to canonical rows — they are dropped but always counted. All workout energies are stored in kcal (active_energy_kcal, total_energy, basal_energy): real HAE v2 payloads send activeEnergyBurned/totalEnergy as {qty, units:'kJ'} objects and activeEnergy/basalEnergy as per-minute kJ point lists, which are summed then converted. Distance (distance_km, walking_running_distance_km), HR, cadence, speed, temperature, humidity and intensity are read from the real {qty, units}/point-list shapes as well as the simplified fixture shapes.

  • KST days: day_kst is a stored generated column with fixed UTC+9 — rollup boundaries are KST calendar days, never server-local time.

  • Rollups: daily_rollups are recomputed per affected day inside the ingest transaction; queries read rollups (90-day series < 1 s).

Deployment (Docker)

Target: a single service container next to any PostgreSQL 18 instance (docker, or anywhere reachable over TCP). Prerequisites: postgres running with db/role health_app created and its port reachable from the container.

The image is self-contained (built with uv, runs as non-root UID 10001) and takes all configuration from environment variables — nothing is baked in.

  1. Build the image:

    docker build -t health-db:latest .

    .dockerignore keeps .git, .env, data/ etc. out of the build context, so no health data or secrets end up in the image.

  2. Prepare the environment (a .env file or your orchestrator's env settings; .env is gitignored — never commit it):

    PSQL_USER=health_app
    PSQL_TABLE=health_app
    PSQL_PASSWORD=<postgres password>
    PSQL_URL=<postgres host>                 # LAN IP/hostname — NOT a container name, unless both run in one compose network
    PSQL_PORT=5432                           # the published postgres port
    INGEST_SECRET=$(openssl rand -hex 32)
    INGEST_HEADER=X-Ingest-Token
    MAX_PAYLOAD_BYTES=67108864

    See .env.example for the full key reference (PSQL_URL is the host; PSQL_TABLE is the database name — HAE's odd key name is preserved for parity).

    How env vars reach the app (they are never baked into the image — image layers are readable by anyone with the image): the image declares every key with an empty default so docker inspect and container wizards list them; the real values are injected at container start from .env / --env / your orchestrator. All override the image defaults. Leaving INGEST_SECRET/PSQL_* blank keeps the app fail-closed: every ingest/MCP request is 401'd until a real secret is supplied, and the health probe never dials ambient localhost:5432.

  3. Create the data folder (orchestrators often don't create bind-mount folders, and the image runs as UID 10001, not your user). The folder must be writable by UID 10001 — both ownership AND mode bits matter:

    mkdir -p /srv/health-db/data
    chown -R 10001:10001 /srv/health-db/data
    chmod -R 755 /srv/health-db/data

    chown alone is not enough when the directory has no write bit (e.g. mode 555): the app gets PermissionError on data/archive. If the container still shows 555, the mounted path is not the one you changed — confirm from the host:

    stat -c '%i %a %u:%g %n' /srv/health-db/data
    docker exec health-db stat -c '%i %a %u:%g' /app/data
  4. Run the container:

    docker run -d --name health-db \
      --env-file .env \
      -p 8000:8000 -p 8100:8100 \
      -v /srv/health-db/data:/app/data \
      --restart unless-stopped \
      health-db:latest

    The entrypoint runs migrations at startup (health-migrate → migrations/0001_init.sql), then serves ingest on :8000 and MCP on :8100. The verbatim archive lives in the mounted data/ folder and survives container recreation.

    With docker compose, the included docker-compose.yml builds the image and adds a disposable postgres:18 for local use (docker compose up --build). For a production run, point PSQL_URL/PSQL_PORT at your real postgres and replace the named volume with a bind mount (or keep the named volume and back it up).

    To ship code changes: git pull, rebuild with step 1, recreate the container.

  5. Verify:

    curl http://<host>:8000/api/health                      # {"status":"ok",...}
    curl -H "X-Ingest-Token: $INGEST_SECRET" http://<host>:8000/api/ingest/stats

HAE app configuration (iPhone)

Three REST automations (JSON, v2 format, Batch Requests ON, header X-Ingest-Token: <INGEST_SECRET>, timeout 60s), pointed at http://<server-host>:8000/api/ingest:

#

Name

Metrics

Grouping

Cadence

1

Metrics-all

all metrics, Summarize ON

hours

hourly

2

Metrics-HR+Steps

Heart Rate + Step Count only

minutes

hourly

3

Workouts

Include Workout Metrics ON (minutes) + Include Route Data ON

—

daily

Automations 1 and 2 are deliberately disjoint to keep hourly payloads small.

Backfill runbook (full history)

History is backfilled through the same ingest endpoint — no SQLite migration.

⚠️ Deploy BEFORE backfill. Never run the full-history backfill against a deployment older than the parse-everything normalizer fix (2026-09-17-normalizer-audit). Older deployments store every Watch night's sleep as 0.0 (asleep:0 sentinel), silently drop the data.ecg / data.heartRateNotifications lanes, and skip lb/st/°F rows — ingesting 8 years through them would bake those defects into the archive. Order:

  1. Land the fixed code, rebuild, redeploy (health-migrate applies migration 0002).

  2. Repair the already-ingested days:

    docker exec health-db health-replay --dir data/archive --force
  3. Verify the repair via MCP before starting the backfill — query_day for a few recent days on sleep_analysis must return non-zero sleep totals (not 0.0).

  4. Only then run the backfill chunks below (ECG/notifications and unit conversions are exercised on first ingestion).

  1. In HAE: Manual Export → JSON v2, per-year date-range chunks (one chunk per year of history), all metrics + workouts, Summarize ON, Batch Requests ON. Each POST is ≤ 64 MiB (MAX_PAYLOAD_BYTES); Batch Requests splits larger exports into sequential requests automatically.

  2. Chunks flow through the same /api/ingest pipeline as live syncs: archived → normalized → upserted → rollups recomputed.

  3. Re-runs are free: re-posting any chunk (or re-running it via health-replay --dir data/archive) changes nothing — sha256 dedup + latest-wins upserts make overlap safe. Verify by re-running a chunk and comparing row counts (spec scenario 3).

  4. If the DB was down during a chunk, the payload sits in data/pending/ (service answered 503); drain with docker exec health-db health-replay --pending.

Repairing rows written by an older normalizer

health-replay --dir short-circuits on raw_ingest.sha256 (duplicate), so it cannot re-normalize payloads already in the DB. To repair rows written by a buggy/older normalizer, run the force/repair path against the archive on disk:

uv run health-replay --dir data/archive --force     # --repair is an alias

This re-normalizes every archived payload even when its sha already exists and re-upserts with latest-wins semantics, so workout/HR/route columns are rewritten with corrected values while raw_samples and daily_rollups are unchanged. It is idempotent — running it twice changes nothing the second time — and never deletes raw_ingest rows (the audit row is updated in place with outcome='repaired'). It reads only from the on-disk archive, so it is safe to run on a live deployment.

Probe-metric cleanup (resolved 2026-10-01)

The ingest auto-registers unknown metrics, so the test-acceptance probes __probe_metric/__probe_metric2 once existed as real rows in the production DB. They have been fully purged: samples/rollups/catalog rows deleted, and the 10 probe payloads removed from both the raw_ingest DB archive and the on-disk archive — the resurrection path is gone. No cleanup script exists anymore; a fresh health-replay --dir data/archive --force cannot re-emit the probes.

Post-deploy validation

  1. Day 0: backfill completes (above); GET /api/ingest/stats shows stored ≈ chunk count, quarantined: 0, unauthorized_attempts: 0.

  2. Days 1–7: leave the 3 automations running. Hourly deltas should land within 5 minutes of the hour — check stats.last_received_at / last_automation and raw_ingest.received_at (spec success criterion 3).

  3. Spot-check parity: for a few captured payloads, daily_rollups values must match the payload's own aggregates (recompute from the archived JSON if in doubt).

  4. Zero unexplained 4xx/5xx: every non-2xx in the week must be accounted for (quarantine / 413 oversize / deliberate 401 probes). Anything else is a bug.

  5. Query checks: freshness shows current-day coverage per metric; a 90-day get_metric_series answers < 1 s (perf contract, tests/acceptance/slice_2_perf.py).

MCP client configuration

MCP endpoint: http://<server-host>:8100/mcp (streamable-http), auth Authorization: Bearer <INGEST_SECRET> (same secret as ingest).

When clients reach MCP by a name other than localhost (server hostname, or a reverse-proxy host:port), set MCP_ALLOWED_HOSTS in .env — a comma-separated list of Host values, e.g. healthbox.example:8443,healthbox.example. The MCP transport validates the Host header (DNS-rebinding protection) against a localhost-only default allowlist; an unlisted name is rejected with 421 Invalid Host header. base:* wildcards are supported (any port on base). The Bearer check remains the access control — this list only decides which hostnames may reach it.

Example client config (Claude Desktop / any streamable-http MCP client):

{
  "mcpServers": {
    "health-db": {
      "type": "http",
      "url": "http://<server-host>:8100/mcp",
      "headers": {
        "Authorization": "Bearer <INGEST_SECRET>"
      }
    }
  }
}

Recommended agent flow: read health://metrics first to ground on the catalog, then get_metric_series / get_trends for rollup-level questions, raw_samples for fine-grained data (limit default 1000, max 10000), freshness_check() before interpreting recent days.

Testing

uv run pytest                       # unit + acceptance (pg tests need docker)
uv run pytest -m requires_pg        # container-backed acceptance only
uv run pytest tests/acceptance/slice_2_perf.py -m requires_pg   # perf contract (<1s @ 90d)
uv run ruff check src/ tests/

Integration tests always run against disposable postgres:18 containers — never a real database. Slice-1 captured real payloads in data/archive/ are the canonical fixtures (never committed).

Environment reference

Key

Meaning

Default

PSQL_URL

postgres host (LAN IP/hostname, or postgres service name in local compose)

localhost

PSQL_PORT

postgres port

5432

PSQL_USER

role name

health_app

PSQL_TABLE

database name (HAE parity naming)

health_app

PSQL_PASSWORD

role password

—

INGEST_SECRET

shared secret (ingest header + MCP Bearer)

—

INGEST_HEADER

ingest header name

X-Ingest-Token

MAX_PAYLOAD_BYTES

413 threshold

67108864 (64 MiB)

HOST / PORT

ingest bind

0.0.0.0:8000

MCP_HOST / MCP_PORT

MCP bind

0.0.0.0:8100

MCP_ALLOWED_HOSTS

extra Host-header names for MCP (reverse proxy etc.), comma-separated

localhost only

Out of scope (later manual steps): TLS termination / reverse-proxy port forwarding; analysis features; predecessor decommission.

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    D
    maintenance
    Enables users to query Apple Health metrics, workouts, and trends from CSV files exported via the Health Auto Export app. It allows MCP clients to analyze health data such as heart rate, sleep stages, and activity levels directly from local iCloud Drive storage.
    3
    9 npm
    2
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    Read-only MCP server that exposes Apple Health data (steps, workouts, sleep, etc.) from a local SQLite store, allowing AI agents to query health metrics without sending data to hosted services.
    9
    30
    Apache 2.0
  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables users to query Polar health data (activity, sleep, recovery, training sessions, heart rate) through MCP with secure authentication, redacted personal info, and bounded responses.
    -