Releasesv0.1.0

Kubernetes with Helm

The Helm chart: an external Postgres, the migration Job, the raw store, values, upgrades.

The chart in deploy/helm/ohs runs Open Health Sync on Kubernetes. It runs the published image (ghcr.io/soma-intelligence/open-health-sync, the chart's appVersion by default) and expects a Postgres 17 with TimescaleDB that you run yourself: a managed service, an operator in the cluster, or a server outside it. The chart is not published to a chart repository yet: install it from a checkout of this repository.

What it creates

  • A Deployment of one pod running ohs serve with the three roles (OHS_ROLES=api,collect,etl), as the compose file does. With splitRoles.enabled, three Deployments instead (-api, -collect, -etl), one role each, so they scale apart. Only the api role serves HTTP.
  • A Job that runs ohs migrate up before every install and upgrade (a Helm pre-install and pre-upgrade hook), so the pods start on an up to date schema and never migrate themselves.
  • A Service on port 8080. Pods that serve HTTP are probed on /healthz (startup and liveness) and /readyz (readiness: the database and the raw store answer).
  • A ConfigMap with the non-secret OHS_* settings, and a Secret with OHS_DATABASE_URL and OHS_ENCRYPTION_KEY (or your own Secret, see existingSecret).
  • A PersistentVolumeClaim for the raw store at /var/lib/ohs/raw, unless the raw store is S3.
  • Optional, all off by default: Ingress, ServiceMonitor, NetworkPolicy, HorizontalPodAutoscaler and PodDisruptionBudget.

The pods run as user 65532 with a read-only root filesystem, no capabilities and no service account token.

One pod, or several

The default raw store is a ReadWriteOnce volume, which one pod at a time can mount. So the default is one replica, and the Deployment uses the Recreate strategy: on an upgrade the old pod stops before the new one starts. The chart refuses to render more than one pod (replicaCount above 1, autoscaling.enabled or splitRoles.enabled) on that volume. To run more, keep raw payloads where every pod can reach them: an S3 bucket (rawStore.type=s3), or a ReadWriteMany volume (rawStore.persistence.accessMode=ReadWriteMany).

Requirements

  • Kubernetes 1.25 or later, Helm 3.
  • Postgres 17 with the TimescaleDB extension available. The first migration runs CREATE EXTENSION IF NOT EXISTS timescaledb: give the chart's database user the right to create it, or create it once yourself as a superuser.
  • A StorageClass for the raw volume (the cluster default is used), or an S3 bucket that supports conditional writes (If-None-Match), see S3 compatible raw store.

Install with an external Postgres

  1. Create the database and the extension (as a superuser, once):

    CREATE ROLE ohs LOGIN PASSWORD 'change-me';
    CREATE DATABASE ohs OWNER ohs;
    \c ohs
    CREATE EXTENSION IF NOT EXISTS timescaledb;
  2. Get the chart:

    git clone https://github.com/soma-intelligence/open-health-sync
    cd open-health-sync
  3. Write your values. Keep this file out of version control: it holds the database password.

    # ohs-values.yaml
    database:
      url: postgres://ohs:change-me@db.example.org:5432/ohs?sslmode=require&pool_max_conns=20
    config:
      publicURL: https://health.example.org

    Size the pool with pool_max_conns in the URL, as described under Database pool size. With split roles each pod opens its own pool: keep the sum over all pods under the server's max_connections.

  4. Install:

    kubectl create namespace ohs
    helm install ohs deploy/helm/ohs -n ohs -f ohs-values.yaml --wait

    The migration Job runs first. If it fails, helm install stops and the Job stays for its logs: kubectl -n ohs logs job/ohs-migrate.

  5. Check the instance and create a first tenant (its API key is printed once):

    kubectl -n ohs exec deploy/ohs -- /ohs doctor
    kubectl -n ohs exec deploy/ohs -- /ohs admin tenant create --name main

    With splitRoles.enabled, use deploy/ohs-api.

  6. Put it behind TLS: enable the chart's Ingress (below) or route to the ohs Service on port 8080 from your own, and set config.publicURL to that https address. OAuth callbacks are built from it: <publicURL>/oauth/<slug>/callback.

The encryption key

OHS_ENCRYPTION_KEY seals the OAuth tokens stored in the database. Leave encryptionKey empty and the chart generates one on the first install, then reads it back from the cluster on every upgrade, so it never changes. The Secret is created as a Helm hook (it has to exist before the migration Job), so helm uninstall keeps it. Back it up with the database:

kubectl -n ohs get secret ohs-secret -o jsonpath='{.data.OHS_ENCRYPTION_KEY}' | base64 -d

A tool that renders the chart without access to the cluster (helm template, some GitOps setups) cannot read the key back and would generate a new one on each render. In that case set encryptionKey (openssl rand -base64 32), or keep every secret in a Secret of your own:

kubectl -n ohs create secret generic ohs-env \
  --from-literal=OHS_DATABASE_URL='postgres://ohs:change-me@db.example.org:5432/ohs?sslmode=require&pool_max_conns=20' \
  --from-literal=OHS_ENCRYPTION_KEY="$(openssl rand -base64 32)" \
  --from-literal=OHS_WITHINGS_CLIENT_ID=... \
  --from-literal=OHS_WITHINGS_CLIENT_SECRET=...
helm install ohs deploy/helm/ohs -n ohs --set existingSecret=ohs-env \
  --set config.publicURL=https://health.example.org --wait

Every key of that Secret becomes an environment variable of the pods, so provider keys and S3 credentials go there too.

Provider keys

Without existingSecret, put provider keys in secretEnv; they land in the chart's Secret:

secretEnv:
  OHS_WITHINGS_CLIENT_ID: "..."
  OHS_WITHINGS_CLIENT_SECRET: "..."

Non-secret provider settings (for example OHS_DEXCOM_REGION) go in config.extra. Every pod gets the same environment, which covers the rule that each process running collect or etl needs the provider keys.

S3 raw store

rawStore:
  type: s3
  s3:
    url: s3://my-ohs-raw?region=eu-west-3
    accessKeyId: "..."        # or AWS_ACCESS_KEY_ID in existingSecret
    secretAccessKey: "..."    # or AWS_SECRET_ACCESS_KEY in existingSecret

For another S3 compatible service add endpoint= and path_style=true to the URL, as in the compose setup. No volume is created.

Values

ValueDefaultMeaning
image.repositoryghcr.io/soma-intelligence/open-health-syncImage
image.tagchart appVersionImage tag; there is no latest while the version is 0.x
image.digestemptysha256:..., wins over the tag
image.pullPolicyIfNotPresent
imagePullSecrets[]
database.urlempty, requiredOHS_DATABASE_URL, unless existingSecret is set
existingSecretemptyYour Secret, holding at least OHS_DATABASE_URL and OHS_ENCRYPTION_KEY; all its keys become environment variables
encryptionKeygenerated onceOHS_ENCRYPTION_KEY, 32 random bytes in base64
secretEnv{}More keys for the chart's Secret (provider keys)
config.publicURLhttp://localhost:8080OHS_PUBLIC_URL: set your https address
config.logLevelinfoOHS_LOG_LEVEL
config.metricstrueOHS_METRICS: /metrics on port 8080
config.retentionDays0OHS_RETENTION_DAYS; 0 keeps everything
config.ingestConcurrency8OHS_INGEST_CONCURRENCY
config.webhookAllowPrivatefalseOHS_WEBHOOK_ALLOW_PRIVATE (0.1.0 and later)
config.bootstrapTenantemptyOHS_BOOTSTRAP_TENANT
config.extra{}Any other non-secret variable
rawStore.typefilefile (a volume) or s3
rawStore.s3.urlemptys3://bucket?region=..., required with s3
rawStore.s3.accessKeyId, secretAccessKeyemptyWritten to the chart's Secret as AWS_*
rawStore.persistence.enabledtruefalse mounts an emptyDir: raw payloads are lost with the pod
rawStore.persistence.existingClaimemptyUse this claim instead of creating one
rawStore.persistence.storageClassemptyEmpty uses the default StorageClass
rawStore.persistence.accessModeReadWriteOnceReadWriteMany allows more than one pod
rawStore.persistence.size10Gi
rawStore.persistence.keeptrueKeep the claim on uninstall
replicaCount1Pods of the single Deployment
resources100m CPU, 256Mi requested; 1Gi memory limitAlso the default of each split role
splitRoles.enabledfalseOne Deployment per role
splitRoles.<role>.replicaCountapi 2, collect 1, etl 1
splitRoles.<role>.resources{}Empty uses resources
migrations.enabledtrueThe ohs migrate up Job; disabled, run it yourself before each upgrade
migrations.backoffLimit, activeDeadlineSeconds2, 600
env, extraEnvFrom[]Extra environment, as Kubernetes env and envFrom entries
service.type, service.portClusterIP, 8080
startupProbe, livenessProbe, readinessProbe/healthz, /healthz, /readyzProbes of the pods that serve HTTP
terminationGracePeriodSeconds35ohs gives work in flight 25 s on SIGTERM
serviceAccountNameemptyAn existing ServiceAccount
podSecurityContext, securityContextnon-root 65532, read-only root
podAnnotations, podLabels, nodeSelector, tolerations, affinity, topologySpreadConstraints, priorityClassNameemptyPod placement
ingress.enabledfalseWith className, annotations, hosts, tls
serviceMonitor.enabledfalseNeeds the Prometheus Operator CRDs; interval, scrapeTimeout, labels
networkPolicy.enabledfalseIngress on 8080 from ingressFrom; egress to DNS, the database (databasePort, databaseTo), HTTPS (allowHTTPS) and extraEgress
autoscaling.enabledfalseCPU based, on the pods that serve HTTP; minReplicas, maxReplicas, targetCPUUtilizationPercentage
podDisruptionBudget.enabledfalseOn the pods that serve HTTP; minAvailable

values.yaml explains each one in place.

Optional resources

  • Ingress. Uploads carry up to 64 MiB of body: raise your controller's limit (with ingress-nginx, nginx.ingress.kubernetes.io/proxy-body-size: 64m). The Ingress routes every path, /metrics included: block it at the controller, or set config.metrics=false (see Operating notes).
  • ServiceMonitor. Scrapes /metrics from the Service.
  • NetworkPolicy. Applies to every pod of the release, the migration Job included. HTTPS on 443 covers the provider APIs, AWS S3 and most webhook endpoints; add an extraEgress rule for an S3 endpoint or a webhook receiver on another port.
  • HorizontalPodAutoscaler and PodDisruptionBudget. Both only make sense with more than one pod, so with a shared raw store. A PodDisruptionBudget with minAvailable: 1 on a single pod blocks node drains.

Upgrading

Read the changelog and take a backup first (the database, the raw store and the encryption key, see Backup and restore). Then:

git pull
helm upgrade ohs deploy/helm/ohs -n ohs -f ohs-values.yaml --wait
kubectl -n ohs exec deploy/ohs -- /ohs version

The migration Job runs before the new pods start. To run another release than the chart's appVersion, set image.tag. There is no step by step downgrade: to go back, run the old version on a restored backup.

Uninstalling

helm uninstall ohs -n ohs

This removes the Deployments, the Service, the ConfigMap and the optional resources. It keeps, on purpose:

  • the Secret ohs-secret with the encryption key (a hook resource),
  • the raw volume claim ohs-raw (rawStore.persistence.keep),
  • the database, which the chart never touches beyond the migrations.

Installing again under the same release name reuses the Secret and the claim. To remove everything, delete them by hand once you no longer need the data:

kubectl -n ohs delete secret ohs-secret
kubectl -n ohs delete pvc ohs-raw
Edit on GitHub

On this page