- TypeScript 94.7%
- JavaScript 4.4%
- Dockerfile 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .woodpecker | ||
| apps/server | ||
| docs | ||
| packages | ||
| scripts | ||
| .dockerignore | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc | ||
| .taskrc.yml | ||
| AGENTS.md | ||
| CLAUDE.md | ||
| compose.yaml | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| mise.toml | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| renovate.json | ||
| Taskfile.yml | ||
| tsconfig.json | ||
| vitest.config.ts | ||
Health
A local-first, single-user health and workout service for completed Gadgetbridge exports. It preserves source provenance and correction history in SQLite, resolves independently clocked heart-rate points with named priority policies, and keeps canonical workout sessions separate from their original device recordings.
This service has no authentication. Its API exposes sensitive health data and permits exclusion changes. Keep the published port on loopback; do not expose it directly to a LAN or the internet. There is no browser application, account model, or medical interpretation layer.
What is implemented
- Read-only import of completed local Gadgetbridge exports, schema 138, with exactly one supported user.
- Ordinary heart rate from Garmin, Huami/Amazfit, and Huawei adapters, including manufacturer-specific timestamps, signed encodings, missing/invalid values, and Huawei synthetic-marker filtering.
- S3-compatible export acquisition with paginated inventory checks, ETag-conditional downloads, private staging, hash-verified retained snapshots, and durable ingestion outcomes.
- Transactional, idempotent reconciliation: stable observation identities, immutable correction revisions, and historical readings retained when later exports omit them.
- Named, immutable-revision device-priority policies and configurable UTC heart-rate aggregation (
1mthrough1w; average, minimum, maximum, first, or last), with quality-aware source fallback and explicit selection provenance. - A first-class workout catalogue with source-reported summary metrics, Garmin FIT/Huawei detail references, immutable recording revisions, explicit cross-device link/reject/unlink decisions, per-metric source selection, and user-owned manual sessions and annotations.
- Persistent manual exclusions, ingestion status/freshness endpoints, generated OpenAPI, and an interactive API reference.
Not implemented: temperature and other health metrics, decoded FIT/Huawei workout samples or routes/laps/intervals, interpolation, local-time/DST aggregation, descriptive workout analytics, authentication, multi-user ingestion, PostgreSQL, or a frontend. Support for the named manufacturers means the adapter tables in this repository, not every device or export format. Unsupported schemas/users are rejected rather than guessed.
Project records
- Data discovery: dated source inventory, coverage, anomalies, interpretation traps, and open questions.
- Implementation plan: implemented baseline, architectural decisions, next milestones, acceptance criteria, and a future-session handoff.
Architecture
| Path | Responsibility |
|---|---|
packages/health (@health/domain) |
Vendor-neutral observations, policies, workout sessions/recordings, source selection and UTC window aggregation; no HTTP, database, or vendor dependencies |
packages/config (@health/config) |
Shared TypeScript and test configuration |
apps/server/src/adapters/{garmin,huami,huawei} |
Manufacturer-specific interpretation |
apps/server/src/ingestion/gadgetbridge |
Completed-export reader, schema checks, adapter dispatch |
apps/server/src/ingestion/snapshots |
S3 transport, staging, retained exports, integrity receipts |
apps/server/src/ingestion/status |
Durable source binding, leases, runs, export freshness |
apps/server/src/ingestion/ingestion.ts |
Acquisition/import orchestration, scheduling, shutdown |
apps/server/src/store / database |
Reconciliation, immutable revisions, policies, workout decisions, queries, exclusions, SQLite lifecycle and ordered migrations |
apps/server/src/api |
Fastify routes with Zod request/response schemas generating OpenAPI |
The runtime is Node 24, Fastify, Zod v4, Kysely, and better-sqlite3. pnpm workspaces retain the same layout in the image under /app. SQLite migrations run on first database access. The application database uses WAL mode and foreign keys.
Local development
Install mise and activate it in your shell, then run from the repository root:
mise install
task install
task build
HEALTH_SYNC_ENABLED=false task start
If mise shims are not active, use mise exec -- task <name>. Node and Task versions are pinned in mise.toml; pnpm is pinned by package.json#packageManager and enabled through Corepack. Native SQLite dependencies need a working compiler toolchain when a prebuilt binary is unavailable; use the pinned Node runtime.
The empty service can start without exports or S3 credentials:
- API reference: http://127.0.0.1:3000/api/docs/
- OpenAPI JSON: http://127.0.0.1:3000/api/openapi.json
- Liveness: http://127.0.0.1:3000/api/health
| Command | Purpose |
|---|---|
task install |
Install workspace dependencies using the frozen lockfile |
task check / task build |
Execute tsc -b across the TypeScript reference graph; both emit build output |
task dev:server |
Run the server from source with watching |
task start |
Build and run the compiled server |
task import |
Import one completed local export |
task sync |
Acquire, retain, and import the configured S3 export once |
task test / task test:coverage |
Run isolated workspace regression tests, optionally with coverage |
task lint |
Run ESLint, including configured formatting rules |
task format / task format:check |
Write/check formatting |
task ci:quality |
Run the CI quality gate locally |
task license:check / task ci:compliance |
Run the production dependency-license gate locally; CI also runs Trivy separately |
task docker:build |
Build the application image, including its runtime smoke check |
task docker:up / task docker:down |
Build/start or stop the Compose deployment; down preserves its named volume |
Tests use synthetic exports and isolated databases, not private .source data. See CLAUDE.md for contributor invariants.
Docker deployment
Docker Engine with Compose is required. The image runs as node, UID/GID 1000, not root. It stores durable state under /data, listens on container port 3000, and uses Node as its entrypoint. No source export or credentials are baked into the image.
Build and run locally
task docker:build
task docker:up
curl --fail http://127.0.0.1:3000/api/health
docker compose logs --tail=50 health
The checked-in Compose service is health, uses code.olsen.cloud/incubator/health-service:latest, and binds 127.0.0.1:3000:3000. Its health-data named volume is mounted at /data. The default deployment has no source bind mount or S3 credentials. Stop it with task docker:down; do not add --volumes/-v unless you intend to delete durable health history and exclusions.
The image's synthetic smoke imports into an isolated directory, exercises the actual HTTP server/API documentation and SQLite runtime, then restarts without the source to check persistence. It does not contact S3 or use your exports. Run it explicitly against an image without mounting private data:
docker run --rm code.olsen.cloud/incubator/health-service:latest scripts/container-smoke.mjs
Run a published image
The Forgejo repository is private. Before pulling published images, authenticate as an account allowed to read its packages, using a dedicated personal access token scoped to read:package. Pass the token over stdin from a protected file or secret manager; never paste it into a command, commit it, or put it in shell history:
docker login code.olsen.cloud --username YOUR_FORGEJO_USERNAME \
--password-stdin < /absolute/private/forgejo-read-package-token
Protect the token file and Docker's credential storage; use a Docker credential helper where available. Registry credentials are separate from the application's read-only S3 credentials. Once authenticated and the main-branch pipeline has published an image:
docker pull code.olsen.cloud/incubator/health-service:latest
docker run -d --name health --restart unless-stopped \
-p 127.0.0.1:3000:3000 \
--mount type=volume,source=health-data,target=/data \
-e HEALTH_SYNC_ENABLED=false \
code.olsen.cloud/incubator/health-service:latest
Use either this standalone container or Compose, not both on the same host port. For repeatable deployments, replace latest with the published eight-character commit SHA tag, and preferably pin the registry digest (@sha256:…): latest moves, and a tag is not registry-enforced immutability. A deployment rollback still needs a compatible database; take a backup before upgrading across migrations.
An empty named volume is initialized from the image's writable /data directory. If using a host bind mount or an existing volume instead, provision it so UID/GID 1000 can traverse and write it; do not solve permissions by running the application as root. Do not mount private directories into the container unless needed.
S3 configuration and one-shot commands
Keep credentials in a private file outside the checkout (restrict it to the deploying user), never in Compose YAML, image build arguments, shell history, or commits. For Docker --env-file, use NAME=value lines containing the variables in the configuration reference below. Docker does not load that file unless you pass it. Environment values remain visible to privileged Docker operators.
To run scheduled synchronization, omit the explicit HEALTH_SYNC_ENABLED=false from the standalone example and add --env-file /absolute/private/health.env before the image name. Include all three required S3 variables and explicitly set HEALTH_SYNC_ENABLED=true if scheduling is intended. With Compose, an override can supply a private env_file; the shipped file deliberately does not auto-load credentials.
Run a single S3 synchronization against the same durable volume:
docker run --rm \
--mount type=volume,source=health-data,target=/data \
--env-file /absolute/private/health.env \
code.olsen.cloud/incubator/health-service:latest apps/server/dist/sync.js
Node is already the entrypoint: pass the JavaScript path, not node again. sync.js exits 0 on imported/unchanged, 1 on failure, and 2 when another process holds the source lease. HEALTH_SYNC_ENABLED=false disables scheduling only; an explicit sync still runs.
Import a completed local export with an optional read-only source mount:
docker run --rm \
--mount type=volume,source=health-data,target=/data \
--mount type=bind,source=/absolute/completed-export,target=/source,readonly \
code.olsen.cloud/incubator/health-service:latest apps/server/dist/import.js
The host export must already exist and be readable by UID 1000. Import exits 1 on failure. Neither one-shot command exposes an HTTP port. For Compose's configured volume/environment, docker compose run --rm --no-deps health apps/server/dist/sync.js runs the same entrypoint; supply S3 settings through your private override first.
Configuration reference
Configuration is loaded once at process startup:
- Built-in defaults.
- Optional JSON file selected by
HEALTH_CONFIG. - Explicit environment overrides from the table below.
The JSON is validated before environment overrides, so an invalid file cannot be repaired by an environment value. The application does not load .env automatically. Local built-in data/source paths resolve from the repository, not the current working directory; custom relative paths resolve from the process working directory. Prefer absolute paths, particularly with workspace commands and containers. Inside the image the working directory is /app.
| Environment variable | JSON property | Default and constraints |
|---|---|---|
HEALTH_CONFIG |
— | Unset; optional readable JSON file path. In a container, mount the file read-only and use its container path. |
HEALTH_DB |
database.filename |
Repository .data/health.db; Docker /data/health.db. Nonempty; :memory: is ephemeral and intended for isolated tests. |
HEALTH_SOURCE_DIR |
source.directory |
Repository .source; Docker /source. Nonempty completed-export directory. |
HEALTH_HOST |
server.host |
127.0.0.1; Docker 0.0.0.0 so Docker port forwarding works. This does not make a non-loopback host publish safe. |
HEALTH_PORT |
server.port |
3000; integer 0–65535. Zero requests an ephemeral port and is not useful with the default Docker port mapping. |
HEALTH_SYNC_ENABLED |
sync.enabled |
Inferred from the presence of all three S3 credential/endpoint environment variables unless explicitly configured. Environment accepts exactly true or false; JSON uses a boolean. |
HEALTH_SYNC_SOURCE_ID |
sync.sourceId |
gadgetbridge-local; 1–200 characters. Shared by local import and S3 sync. |
HEALTH_SYNC_DIR |
sync.directory |
Repository .data/ingestion; Docker /data/ingestion. Nonempty; durable retained exports and integrity receipts, not a disposable cache. |
HEALTH_SYNC_INTERVAL_SECONDS |
sync.intervalSeconds |
86400 (one day); integer 60–604800. |
HEALTH_SYNC_STALE_AFTER_SECONDS |
sync.staleAfterSeconds |
172800 (two days); integer 60–2592000. |
HEALTH_SYNC_MAX_RUN_SECONDS |
sync.maxRunSeconds |
1800; integer 1–1800. Bounds the synchronization attempt; lease includes a further 300-second safety window. |
S3_ENDPOINT_URL |
sync.endpoint |
Unset; valid URL, required for S3 sync. Prefer HTTPS. |
S3_ACCESS_KEY |
sync.accessKeyId |
Unset; nonempty, required for S3 sync. |
S3_SECRET_KEY |
sync.secretAccessKey |
Unset; nonempty, required for S3 sync. |
S3_REGION |
sync.region |
us-east-1; nonempty signing region. |
S3_BUCKET |
sync.bucket |
shared; nonempty. |
S3_PREFIX |
sync.prefix |
gadgetbridge/; nonempty, trailing slash added when absent. The whole prefix inventory is acquired. |
Docker sets the five path/listen defaults shown above as environment variables. They therefore take precedence over those same properties in a mounted JSON file; override the corresponding Docker environment variables when changing them.
A credential-free JSON example:
{
"database": { "filename": "/data/health.db" },
"server": { "host": "0.0.0.0", "port": 3000 },
"source": { "directory": "/source" },
"sync": {
"enabled": false,
"sourceId": "gadgetbridge-local",
"directory": "/data/ingestion",
"intervalSeconds": 86400,
"staleAfterSeconds": 172800,
"maxRunSeconds": 1800
}
}
If endpoint and credentials live only in JSON, their presence does not enable scheduling: inference checks the process environment, not the parsed JSON. Set sync.enabled: true or HEALTH_SYNC_ENABLED=true explicitly. Explicit JSON false also wins over credential inference unless overridden by HEALTH_SYNC_ENABLED.
Least-privilege S3 access
The service only lists and reads: grant s3:ListBucket on the configured bucket, restricted to the configured prefix, and s3:GetObject on objects under that prefix. An encrypted backend may additionally require its read/decrypt permission. No PutObject, DeleteObject, bucket administration, or remote writes are needed or performed. There is no ambient AWS credential-chain/session-token configuration; the application supplies the configured access key and secret explicitly. Use a dedicated read-only principal rather than the publisher's credentials.
The prefix must contain a completed export layout, not unrelated objects. A changed inventory causes a full new snapshot download. An identical inventory reuses a retained snapshot only after integrity verification. Inventory comparison before/after acquisition and conditional downloads establish an observed-stable prefix, not an atomic publisher snapshot. Publish complete exports, and avoid updating them during acquisition.
Imports, scheduling, and durable state
A local export directory needs gadgetbridge.json (Gadgetbridge app ID, backup version 1, UTC millisecond backupDate) and database/Gadgetbridge (SQLite schema 138). Use a completed export, not a live database or one dependent on -wal/-journal sidecars. The reader checks metadata/file stability and SQLite integrity and decodes the exact bytes it fingerprints; it never opens a writable connection against the source.
HEALTH_SOURCE_DIR=/absolute/completed-export task import
# With S3 variables explicitly exported in your shell:
task sync
The local import CLI does not acquire S3 snapshots or create a synchronization run; it imports directly into the canonical store. Both CLIs default to the same source identity, suitable for one continuous single-user export lineage. Do not reuse that identity for an unrelated export or change it on each run. Local vendor database IDs are not globally stable device identities. A synchronized source is durably bound to its endpoint/bucket/prefix; changing that origin under the same source ID is rejected. A genuinely new lineage needs its own source ID and will have separate observations/exclusions.
The listening server starts the scheduler when enabled. It checks immediately if due; later checks follow persisted run start times, so restarts do not reset the interval. Keep the server running for automatic checks. Source leases in the shared application database prevent concurrent S3 runs across processes. Expired owners are recorded as interrupted; shutdown coordinates cancellation before closing SQLite. Separate application databases do not share that lease.
Successful imports publish atomically. Replaying unchanged input is a no-op. Corrections preserve observation and workout-recording UUIDs and add immutable revisions. User exclusions, workout annotations, manual sessions, link rejections and metric-source choices are stored independently from imported snapshots. An older export cannot replace newer imported data, and an absent row in a new export is not a deletion instruction. Import commit and synchronization-run completion are separate transactions; leases and idempotent replay recover interrupted completion.
Status and freshness
curl --fail http://127.0.0.1:3000/api/ingestion/status
curl --fail 'http://127.0.0.1:3000/api/ingestion/runs?limit=20'
- Run states are
running,imported,unchanged,failed, andinterrupted. Counts, timing, hashes, and sanitized error codes/messages are durable. Runs are newest first;limitis 1–100, default 20. lastRunandlastSuccessare distinct: a recent failure does not erase the last successful import/check.exportFreshnessmeasures age of export metadata capture time, not the latest successful check or sensor coverage. It isneverwithout a known capture,stalewhen age is at least the configured threshold, otherwisefresh. Local imported snapshots supply capture time when no successful synchronization exists.nextCheckAtis null when scheduling is disabled, otherwise follows the active lease or persisted run schedule. It is a scheduling indication, not proof that a running server will complete that check./api/healthreturns{ "status": "ok" }for HTTP liveness; it does not certify S3 connectivity, fresh data, or complete sensor coverage.
A successful unchanged check of an old export remains stale. Retained snapshots, measurement revisions, exclusions, and run history are durable state. There is no automatic pruning policy; monitor disk space and back up before managing retention manually.
Querying health data and workouts
The API reference at /api/docs/ and OpenAPI at /api/openapi.json are the canonical HTTP contract.
Client-correctable 400 responses include issues with a JSON-pointer-like path and human-readable message; for example, an unqualified timeline query before creating a default policy explains No default device priority policy. Responses never echo submitted values.
| Endpoint | Purpose |
|---|---|
GET /api/measurements |
Discover scalar metric IDs, descriptions, units, ranges, and query capabilities |
GET /api/devices |
Discover imported device UUIDs |
GET /api/device-priority-policies |
List the current revision of each named policy |
POST /api/device-priority-policies |
Create a named policy and immutable revision 1 |
GET /api/device-priority-policies/:id?revision=N |
Read the current or a historical revision |
PUT /api/device-priority-policies/:id |
Save the complete rules as the next immutable revision |
GET /api/measurements/:metric |
Resolve and aggregate a scalar metric using a saved policy or explicit device order |
GET /api/measurements/:metric/observations |
Page raw measurements; optional deviceId, limit 1–100, offset 0–1000000 |
GET /api/observations/:id |
Inspect an observation's original quality, provenance, and exclusion |
PUT / DELETE /api/observations/:id/exclusion |
Flag or restore a bad datapoint |
GET /api/exclusions?limit=20&offset=0 |
List excluded observations |
GET / POST /api/workouts |
Page canonical sessions or create a user-owned manual session |
GET /api/workouts/:id |
Inspect all source recordings, reported metrics, detail references, and metric selections |
PUT /api/workouts/:id/annotations |
Replace the user-owned title and note |
PUT /api/workouts/:id/manual-definition |
Replace a manual session's authoritative activity and bounds |
GET /api/workout-link-candidates |
Page compatible cross-device recording pairs with substantial temporal overlap |
PUT /api/workout-link-decisions |
Link, reject, or clear a rejection between two source recordings |
DELETE /api/workouts/:sessionId/recordings/:recordingId |
Split a recording back into its own session and prevent automatic re-suggestion |
PUT /api/workouts/:sessionId/metric-sources/:metricKey |
Select one linked recording for one source-reported metric; null restores automatic source choice |
The service stores scalar samples in a metric-keyed measurement table; heart_rate is currently the only supported metric. Use /api/measurements as the machine-readable catalogue rather than hard-coding IDs, units, valid ranges, supported windows, or aggregation methods.
Workout queries unlock frequency, duration, activity-type and like-session comparisons while retaining the evidence needed for later workout-to-recovery analysis. The catalogue endpoint is bounded and returns summaries; detail is requested one session at a time. Imported BASE_ACTIVITY_SUMMARY rows are the source-recording identity. A matching Huawei summary enriches that recording rather than creating another workout, and a Garmin details path remains a FIT reference. Source metric keys and units remain verbatim because equivalence across vendors has not been established.
Temporal overlap and a matching known activity create only a link candidate. The service never automatically merges recordings. Linking keeps every recording's original bounds and totals; resolved session metrics select one recording per key and are never added across overlapping sources. Rejections, unlinking, manual metric choices, annotations and manual sessions survive later imports. Clearing a rejection is explicit. Current imports retain Huawei detail counts and FIT paths, but do not yet decode samples, routes, laps, sections or intervals.
Create the initial default after obtaining the real UUIDs from /api/devices. Arrays are in descending priority. For the current wearing pattern, put every workout-capable device first, the Helio Strap next, and the Helio Ring last:
curl --fail -X POST \
-H 'Content-Type: application/json' \
-d "{\"name\":\"Workout then continuous\",\"isDefault\":true,\"defaultDevicePriority\":[\"$WORKOUT_DEVICE_1\",\"$WORKOUT_DEVICE_2\",\"$WORKOUT_DEVICE_3\",\"$STRAP_ID\",\"$RING_ID\"],\"metricOverrides\":{}}" \
http://127.0.0.1:3000/api/device-priority-policies
curl --fail --get http://127.0.0.1:3000/api/measurements/heart_rate \
--data-urlencode 'from=2026-01-01T00:00:00Z' \
--data-urlencode 'to=2026-01-02T00:00:00Z' \
--data-urlencode 'interval=5m' \
--data-urlencode 'aggregation=avg'
interval accepts 1m, 5m, 15m, 30m, 1h, 6h, 1d, or 1w; aggregation accepts avg, min, max, first, or last. They default to 1m and avg. from and to must align to the requested UTC interval and select [from,to). A request is limited to 100,000 possible output windows; choose a coarser interval for longer spans. The limit follows response cardinality rather than an arbitrary number of days, so multi-year hourly, daily, or weekly trend requests are supported.
Supply policyId and optionally policyRevision to reproduce a saved revision. A comma-separated devicePriority is an explicit request-only policy: only those devices participate, so a one-device list isolates that source. It cannot be combined with policy parameters. When neither is supplied, the current default is resolved once and its exact revision is returned in selection.
Policy names are unique and immutable. PUT stores complete rules as the next revision; historical revisions remain readable. Selecting a new default clears the previous default atomically. A current default cannot simply be unset, because that would break unqualified timeline requests. Unknown, duplicate, empty, and over-32-device lists are rejected.
Source selection happens before output aggregation. Each observed UTC minute independently selects the highest-priority device with at least one valid, non-excluded point; lower-priority devices fill otherwise uncovered minutes. avg first averages the selected samples within each covered minute, then weights those minute means equally so device sampling frequency cannot bias a wider window. min and max inspect all selected samples; first and last follow observation time. Empty output windows are omitted; a window containing observations but no eligible points is null. There is no carry-forward, interpolation, inferred recording span, or implicit inclusion of unlisted devices.
Each aggregate reports start, exclusive end, every selected device in deviceIds, sampleCount, and minute-level observedMinutes/coveredMinutes. It deliberately does not embed contributing observation IDs: aggregate payload scales with output windows, not raw input cardinality. The response reports the effective policy identity/revision or explicit priority, plus the interval and aggregation method. Imported points remain unchanged; all selection and aggregation happen at request time, so late imports, corrections, exclusions, policy revisions, and alternative aggregation requests do not rewrite source data.
Use the separately paginated raw-measurement endpoint to inspect provenance and obtain IDs for exclusion. It selects [from,to) without applying aggregation or a priority policy; pass deviceId to isolate a source:
curl --fail --get http://127.0.0.1:3000/api/measurements/heart_rate/observations \
--data-urlencode 'from=2026-01-01T00:00:00Z' \
--data-urlencode 'to=2026-01-01T00:05:00Z' \
--data-urlencode "deviceId=$WATCH_ID" \
--data-urlencode 'limit=100'
curl --fail "http://127.0.0.1:3000/api/observations/$OBSERVATION_ID"
curl --fail -X PUT \
-H 'Content-Type: application/json' \
-d '{"reason":"Known sensor artifact"}' \
"http://127.0.0.1:3000/api/observations/$OBSERVATION_ID/exclusion"
curl --fail 'http://127.0.0.1:3000/api/exclusions?limit=20&offset=0'
# Restore only when intended:
curl --fail -X DELETE \
"http://127.0.0.1:3000/api/observations/$OBSERVATION_ID/exclusion"
reason is required but can be null; strings are trimmed and must contain 1–500 characters afterward. Repeated PUT updates the reason while preserving the original exclusion time. DELETE removes the overlay; original vendor quality still determines eligibility. Deleting an already-restored exclusion succeeds, while an unknown observation UUID returns 404. Exclusions do not change vendor quality, raw values, or import history. They are keyed by stable (metric, source_id, external_id), so reimports, corrections, temporarily absent rows, and regenerated observation UUIDs do not silently restore data. A genuinely changed vendor source key is a different identity and is not fuzzy-matched. Timeline resolution can fall back to another selected device at the excluded minute.
Backups and recovery
Treat .data locally, or the entire /data volume in Docker, as private durable storage. The SQLite database contains canonical history, correction revisions, manual exclusions, source bindings, leases, and run records. /data/ingestion contains retained exports and integrity receipts. Source exports alone cannot reconstruct manual exclusions or all historical state.
Never copy only a live health.db file. WAL mode may have committed data in health.db-wal. For a straightforward consistent backup, stop all writers, including the server, one-shot imports/syncs, and any other process sharing the volume. Keep them stopped until the full-volume copy finishes; include any SQLite sidecars that remain.
For the shipped Compose deployment, with an existing private backup directory:
docker compose stop health
docker run --rm --user 0 --entrypoint tar \
--mount type=volume,source=health-data,target=/data,readonly \
--mount type=bind,source=/absolute/private/backups,target=/backup \
code.olsen.cloud/incubator/health-service:latest \
-czf /backup/health-data.tar.gz -C /data .
docker compose start health
Use a new archive filename for each backup instead of overwriting your only recovery point. The helper uses root only to read all owned files and write the archive; the application remains nonroot. With a standalone deployment use docker stop health / docker start health instead. Protect and, where appropriate, encrypt backups as health data; restrict access to the host backup directory. Back up external configuration separately using a secrets-aware mechanism, not in the repository.
For recovery, stop all writers, restore the whole archive into an empty replacement volume, preserve file ownership (UID/GID 1000), and attach that volume to the compatible application image before restarting. Keep the original volume until you have checked devices, exclusions, and ingestion status. Do not combine a database from one backup with retained snapshots from another or overwrite a live volume. Local backups follow the same stopped-writer rule for the configured database and ingestion directories.
For Docker volume mechanics, see the Docker volume backup documentation.
CI and image publishing
Woodpecker workflows in .woodpecker/ run quality and compliance gates on push, pull-request, and manual events. Quality builds the TypeScript reference graph, lints, and runs regression coverage. Compliance checks production dependency licenses and scans the repository with Trivy. The image build uses the shared Kaniko plugin, not Docker-in-Docker, and must pass the Dockerfile's executable synthetic runtime smoke before publishing. A subsequent image scan checks the published image; it is not a pre-push gate.
Publishing is configured only for push or manual events on main, after the quality and compliance workflows succeed. The target is code.olsen.cloud/incubator/health-service, tagged latest and the first eight characters of the commit SHA. PRs do not publish. There is no npm publishing or semantic-version release automation. CI registry credentials come from the shared forgejo_registry_auth secret, never application S3 credentials.
Renovate configuration and a cron workflow maintain dependencies, mise pins, and container references. The Forgejo/Woodpecker administrator must enable the repository, provide the Renovate token, and configure its cron schedule; checked-in YAML alone is not evidence of a scheduled run. Likewise, workflow configuration is not proof that an image is available: inspect the actual pipeline result and registry before deploying a new SHA.